Skip to content

Commit e2eb845

Browse files
committed
feat: add surviving mutant analysis
1 parent 60c61b0 commit e2eb845

File tree

6 files changed

+225
-133
lines changed

6 files changed

+225
-133
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66
name = 'mutahunter'
77
description = "LLM Mutation Testing for any programming language"
88
requires-python = ">= 3.11"
9-
version = "1.1.9"
9+
version = "1.2.0"
1010
dependencies = [
1111
"tree-sitter==0.21.3",
1212
'tree_sitter_languages==1.10.2',
@@ -18,6 +18,7 @@ dependencies = [
1818
'litellm',
1919
"numpy",
2020
"scipy",
21+
'md2pdf',
2122
]
2223
keywords = ["mutahunter", 'test', "testing", "LLM", 'mutant']
2324
readme = "README.md"

src/mutahunter/core/controller.py

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from uuid import uuid4
66

77
from tqdm import tqdm
8-
8+
from md2pdf.core import md2pdf
9+
from jinja2 import Template
10+
import json
911
from mutahunter.core.analyzer import Analyzer
1012
from mutahunter.core.coverage_processor import CoverageProcessor
1113
from mutahunter.core.db import MutationDatabase
@@ -23,7 +25,10 @@
2325
from mutahunter.core.io import FileOperationHandler
2426
from mutahunter.core.llm_mutation_engine import LLMMutationEngine
2527
from mutahunter.core.logger import logger
26-
from mutahunter.core.prompts.mutant_generator import MUTANT_ANALYSIS
28+
from mutahunter.core.prompts.mutant_generator import (
29+
SYSTEM_PROMPT_MUTANT_ANALYSUS,
30+
USER_PROMPT_MUTANT_ANALYSIS,
31+
)
2732
from mutahunter.core.report import MutantReport
2833
from mutahunter.core.router import LLMRouter
2934
from mutahunter.core.runner import MutantTestRunner
@@ -52,8 +57,11 @@ def __init__(
5257
self.mutant_report = mutant_report
5358
self.file_handler = file_handler
5459

60+
self.current_run_id = None
61+
5562
def run(self) -> None:
5663
start = time.time()
64+
self.current_run_id = self.db.start_new_run(self.config.test_command)
5765
try:
5866
self.run_coverage_analysis()
5967
except CoverageAnalysisError as e:
@@ -67,7 +75,7 @@ def run(self) -> None:
6775
self.generate_report()
6876
except ReportGenerationError as e:
6977
logger.error(f"Report generation failed: {str(e)}")
70-
# self._run_mutant_analysis()
78+
self.run_mutant_analysis()
7179
logger.info(f"Mutation Testing Ended. Took {round(time.time() - start)}s")
7280

7381
def run_coverage_analysis(self) -> None:
@@ -174,7 +182,7 @@ def process_mutations(
174182
mutant_data["error_msg"] = str(e)
175183

176184
# Write complete mutant data to database
177-
self.db.add_mutant(file_version_id, mutant_data)
185+
self.db.add_mutant(self.current_run_id, file_version_id, mutant_data)
178186

179187
def test_mutant(
180188
self,
@@ -217,38 +225,37 @@ def generate_report(self) -> None:
217225
self.mutant_report.generate_report(
218226
total_cost=self.router.total_cost,
219227
line_rate=self.coverage_processor.line_coverage_rate,
228+
run_id=self.current_run_id,
220229
)
221230
except Exception as e:
222231
raise ReportGenerationError(f"Failed to generate report: {str(e)}")
223232

224-
# def _run_mutant_analysis(self) -> None:
225-
# """
226-
# Runs mutant analysis on the generated mutants.
227-
# """
228-
# survived_mutants = [m for m in self.mutants if m.status == "SURVIVED"]
229-
230-
# source_file_paths = []
231-
# for mutant in survived_mutants:
232-
# if mutant.source_path not in source_file_paths:
233-
# source_file_paths.append(mutant.source_path)
234-
235-
# src_code_list = []
236-
# for file_path in source_file_paths:
237-
# with open(file_path, "r", encoding="utf-8") as f:
238-
# src_code = f.read()
239-
# src_code_list.append(
240-
# f"## Source File: {file_path}\n```{filename_to_lang(file_path)}\n{src_code}\n```"
241-
# )
242-
243-
# prompt = {
244-
# "system": "",
245-
# "user": Template(MUTANT_ANALYSIS).render(
246-
# source_code="\n".join(src_code_list),
247-
# surviving_mutants=survived_mutants,
248-
# ),
249-
# }
250-
# mode_response, _, _ = self.router.generate_response(
251-
# prompt=prompt, streaming=False
252-
# )
253-
# with open("logs/_latest/mutant_analysis.md", "w") as f:
254-
# f.write(mode_response)
233+
def run_mutant_analysis(self) -> None:
234+
"""
235+
Runs mutant analysis on the generated mutants.
236+
"""
237+
mutants = self.db.get_survived_mutants_by_run_id(run_id=self.current_run_id)
238+
mutants_by_files = {}
239+
for mutant in mutants:
240+
if mutant["file_path"] not in mutants_by_files:
241+
mutants_by_files[mutant["file_path"]] = []
242+
else:
243+
mutants_by_files[mutant["file_path"]].append(mutant)
244+
245+
for k, v in mutants_by_files.items():
246+
with open(k, "r", encoding="utf-8") as f:
247+
src_code = f.read()
248+
prompt = {
249+
"system": Template(SYSTEM_PROMPT_MUTANT_ANALYSUS).render(),
250+
"user": Template(USER_PROMPT_MUTANT_ANALYSIS).render(
251+
source_code=src_code,
252+
surviving_mutants=json.dumps(v, indent=2),
253+
),
254+
}
255+
mode_response, _, _ = self.router.generate_response(
256+
prompt=prompt, streaming=True
257+
)
258+
md2pdf(
259+
pdf_file_path=f"logs/_latest/llm/audit_{str(uuid4())[:4]}.pdf",
260+
md_content=mode_response,
261+
)

src/mutahunter/core/db.py

Lines changed: 139 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sqlite3
44
from contextlib import contextmanager
55
from typing import Any, Dict, List, Optional, Tuple
6+
from datetime import datetime
67

78

89
class DatabaseError(Exception):
@@ -44,9 +45,20 @@ def create_tables(self):
4445
UNIQUE (source_file_id, version_hash)
4546
);
4647
48+
CREATE TABLE IF NOT EXISTS Runs (
49+
id INTEGER PRIMARY KEY,
50+
command_line TEXT NOT NULL,
51+
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
52+
end_time TIMESTAMP,
53+
execution_time REAL,
54+
mutation_score REAL,
55+
line_coverage REAL
56+
);
57+
4758
CREATE TABLE IF NOT EXISTS Mutants (
4859
id INTEGER PRIMARY KEY,
4960
file_version_id INTEGER,
61+
run_id INTEGER,
5062
status TEXT,
5163
type TEXT,
5264
line_number INTEGER,
@@ -57,11 +69,39 @@ def create_tables(self):
5769
error_msg TEXT,
5870
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
5971
FOREIGN KEY (file_version_id) REFERENCES FileVersions(id)
72+
FOREIGN KEY (run_id) REFERENCES Runs(id)
6073
);
6174
"""
6275
)
6376
conn.commit()
6477

78+
def start_new_run(self, command_line: str) -> int:
79+
"""
80+
Start a new run by inserting a record into the Runs table.
81+
82+
Args:
83+
command_line (str): The command line used to start the mutation testing run.
84+
85+
Returns:
86+
int: The ID of the newly created run.
87+
"""
88+
with self.get_connection() as conn:
89+
try:
90+
cursor = conn.cursor()
91+
current_time = datetime.now().isoformat()
92+
cursor.execute(
93+
"""
94+
INSERT INTO Runs (command_line, start_time)
95+
VALUES (?, ?)
96+
""",
97+
(command_line, current_time),
98+
)
99+
conn.commit()
100+
return cursor.lastrowid
101+
except sqlite3.Error as e:
102+
conn.rollback()
103+
raise DatabaseError(f"Failed to start new run: {str(e)}")
104+
65105
def get_file_version(self, file_path: str) -> Tuple[int, int, bool]:
66106
"""
67107
Get or create a file version for the given file path.
@@ -114,18 +154,19 @@ def get_file_version(self, file_path: str) -> Tuple[int, int, bool]:
114154
conn.rollback()
115155
raise DatabaseError(f"Error processing file version: {str(e)}")
116156

117-
def add_mutant(self, file_version_id: int, mutant_data: dict):
157+
def add_mutant(self, run_id: int, file_version_id: int, mutant_data: dict):
118158
with self.get_connection() as conn:
119159
cursor = conn.cursor()
120160
try:
121161
cursor.execute(
122162
"""
123163
INSERT INTO Mutants (
124-
file_version_id, status, type, line_number,
164+
run_id, file_version_id, status, type, line_number,
125165
original_code, mutated_code, description, mutant_path, error_msg
126-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
166+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
127167
""",
128168
(
169+
run_id,
129170
file_version_id,
130171
mutant_data["status"],
131172
mutant_data["type"],
@@ -223,6 +264,44 @@ def update_mutant_status(self, mutant_id: int, status: str):
223264
conn.rollback()
224265
raise DatabaseError(f"Error updating mutant status: {str(e)}")
225266

267+
def get_survived_mutants_by_run_id(self, run_id: int) -> List[Dict[str, Any]]:
268+
with self.get_connection() as conn:
269+
cursor = conn.cursor()
270+
try:
271+
cursor.execute(
272+
"""
273+
SELECT
274+
m.id,
275+
sf.file_path,
276+
m.line_number,
277+
m.mutant_path,
278+
m.original_code,
279+
m.mutated_code,
280+
m.description,
281+
m.type
282+
FROM Mutants m
283+
JOIN FileVersions fv ON m.file_version_id = fv.id
284+
JOIN SourceFiles sf ON fv.source_file_id = sf.id
285+
WHERE m.run_id = ? AND m.status = 'SURVIVED'
286+
""",
287+
(run_id,),
288+
)
289+
return [
290+
{
291+
"id": row[0],
292+
"file_path": row[1],
293+
"line_number": row[2],
294+
"mutant_path": row[3],
295+
"original_code": row[4],
296+
"mutated_code": row[5],
297+
"description": row[6],
298+
"type": row[7],
299+
}
300+
for row in cursor.fetchall()
301+
]
302+
except sqlite3.Error as e:
303+
raise DatabaseError(f"Error fetching survived mutants: {str(e)}")
304+
226305
def get_survived_mutants(self, source_file_path):
227306
with self.get_connection() as conn:
228307
cursor = conn.cursor()
@@ -324,7 +403,50 @@ def get_file_mutations(self, file_name: str) -> List[Dict[str, Any]]:
324403
except sqlite3.Error as e:
325404
raise DatabaseError(f"Error fetching file mutations: {str(e)}")
326405

327-
def get_file_data(self) -> List[Dict[str, Any]]:
406+
def get_mutant_summary(self, run_id) -> Dict[str, int]:
407+
with self.get_connection() as conn:
408+
cursor = conn.cursor()
409+
try:
410+
cursor.execute(
411+
"""
412+
SELECT
413+
COUNT(*) as total_mutants,
414+
SUM(CASE WHEN status = 'KILLED' THEN 1 ELSE 0 END) as killed_mutants,
415+
SUM(CASE WHEN status = 'SURVIVED' THEN 1 ELSE 0 END) as survived_mutants,
416+
SUM(CASE WHEN status = 'TIMEOUT' THEN 1 ELSE 0 END) as timeout_mutants,
417+
SUM(CASE WHEN status = 'COMPILE_ERROR' THEN 1 ELSE 0 END) as compile_error_mutants,
418+
SUM(CASE WHEN status = 'SYNTAX_ERROR' THEN 1 ELSE 0 END) as syntax_error_mutants,
419+
SUM(CASE WHEN status = 'UNEXPECTED_TEST_ERROR' THEN 1 ELSE 0 END) as unexpected_test_error_mutants
420+
FROM Mutants
421+
WHERE run_id = ?
422+
""",
423+
(run_id,),
424+
)
425+
result = cursor.fetchone()
426+
if result[0] == 0:
427+
return None
428+
429+
else:
430+
valid_mutants = (
431+
result[0] - result[3] - result[4] - result[5] - result[6]
432+
)
433+
mutation_coverage = (
434+
result[1] / valid_mutants if valid_mutants > 0 else 0.0
435+
)
436+
return {
437+
"total_mutants": result[0],
438+
"killed_mutants": result[1],
439+
"survived_mutants": result[2],
440+
"timeout_mutants": result[3],
441+
"compile_error_mutants": result[4],
442+
"syntax_error_mutants": result[5],
443+
"unexpected_test_error_mutants": result[6],
444+
"mutation_coverage": mutation_coverage,
445+
}
446+
except sqlite3.Error as e:
447+
raise DatabaseError(f"Error fetching mutant summary: {str(e)}")
448+
449+
def get_file_data(self, run_id: int) -> List[Dict[str, Any]]:
328450
with self.get_connection() as conn:
329451
cursor = conn.cursor()
330452
try:
@@ -335,12 +457,18 @@ def get_file_data(self) -> List[Dict[str, Any]]:
335457
sf.file_path,
336458
COUNT(m.id) as total_mutants,
337459
SUM(CASE WHEN m.status = 'KILLED' THEN 1 ELSE 0 END) as killed_mutants,
338-
SUM(CASE WHEN m.status = 'SURVIVED' THEN 1 ELSE 0 END) as survived_mutants
460+
SUM(CASE WHEN m.status = 'SURVIVED' THEN 1 ELSE 0 END) as survived_mutants,
461+
SUM(CASE WHEN m.status = 'TIMEOUT' THEN 1 ELSE 0 END) as timeout_mutants,
462+
SUM(CASE WHEN m.status = 'COMPILE_ERROR' THEN 1 ELSE 0 END) as compile_error_mutants,
463+
SUM(CASE WHEN m.status = 'SYNTAX_ERROR' THEN 1 ELSE 0 END) as syntax_error_mutants,
464+
SUM(CASE WHEN m.status = 'UNEXPECTED_TEST_ERROR' THEN 1 ELSE 0 END) as unexpected_test_error_mutants
339465
FROM SourceFiles sf
340466
JOIN FileVersions fv ON sf.id = fv.source_file_id
341467
JOIN Mutants m ON fv.id = m.file_version_id
468+
WHERE m.run_id = ?
342469
GROUP BY sf.file_path
343-
"""
470+
""",
471+
(run_id,),
344472
)
345473
return [
346474
{
@@ -350,41 +478,18 @@ def get_file_data(self) -> List[Dict[str, Any]]:
350478
"mutationCoverage": (
351479
f"{(row[3] / row[2] * 100):.2f}" if row[2] > 0 else "0.00"
352480
),
481+
"killedMutants": row[3],
353482
"survivedMutants": row[4],
483+
"timeoutMutants": row[5],
484+
"compileErrorMutants": row[6],
485+
"syntaxErrorMutants": row[7],
486+
"unexpectedTestErrorMutants": row[8],
354487
}
355488
for row in cursor.fetchall()
356489
]
357490
except sqlite3.Error as e:
358491
raise DatabaseError(f"Error fetching file data: {str(e)}")
359492

360-
def get_mutant_summary(self) -> Dict[str, int]:
361-
with self.get_connection() as conn:
362-
cursor = conn.cursor()
363-
try:
364-
cursor.execute(
365-
"""
366-
SELECT
367-
COUNT(*) as total_mutants,
368-
SUM(CASE WHEN status = 'KILLED' THEN 1 ELSE 0 END) as killed_mutants,
369-
SUM(CASE WHEN status = 'SURVIVED' THEN 1 ELSE 0 END) as survived_mutants,
370-
SUM(CASE WHEN status = 'TIMEOUT' THEN 1 ELSE 0 END) as timeout_mutants,
371-
SUM(CASE WHEN status = 'COMPILE_ERROR' THEN 1 ELSE 0 END) as compile_error_mutants
372-
FROM Mutants
373-
"""
374-
)
375-
result = cursor.fetchone()
376-
if result[0] == 0:
377-
return None
378-
return {
379-
"total_mutants": result[0],
380-
"killed_mutants": result[1],
381-
"survived_mutants": result[2],
382-
"timeout_mutants": result[3],
383-
"compile_error_mutants": result[4],
384-
}
385-
except sqlite3.Error as e:
386-
raise DatabaseError(f"Error fetching mutant summary: {str(e)}")
387-
388493
def remove_mutants_by_file_version_id(self, file_version_id: int):
389494
with self.get_connection() as conn:
390495
cursor = conn.cursor()

0 commit comments

Comments
 (0)