. */ require_once 'phing/Task.php'; require_once 'phing/system/io/PhingFile.php'; require_once 'phing/system/util/Properties.php'; /** * Stops the build if any of the specified coverage threshold was not reached * * @author Benjamin Schultz * @version $Id: CoverageThresholdTask.php 905 2010-10-05 16:28:03Z mrook $ * @package phing.tasks.ext.coverage * @since 2.4.1 */ class CoverageThresholdTask extends Task { /** * Holds an optional classpath * * @var Path */ private $_classpath = null; /** * Holds an optional database file * * @var PhingFile */ private $_database = null; /** * Holds the coverage threshold for the entire project * * @var integer */ private $_perProject = 25; /** * Holds the coverage threshold for any class * * @var integer */ private $_perClass = 25; /** * Holds the coverage threshold for any method * * @var integer */ private $_perMethod = 25; /** * Holds the minimum found coverage value for a class * * @var integer */ private $_minClassCoverageFound = null; /** * Holds the minimum found coverage value for a method * * @var integer */ private $_minMethodCoverageFound = null; /** * Number of statements in the entire project * * @var integer */ private $_projectStatementCount = 0; /** * Number of covered statements in the entire project * * @var integer */ private $_projectStatementsCovered = 0; /** * Whether to enable detailed logging * * @var boolean */ private $_verbose = false; /** * Sets an optional classpath * * @param Path $classpath The classpath */ public function setClasspath(Path $classpath) { if ($this->_classpath === null) { $this->_classpath = $classpath; } else { $this->_classpath->append($classpath); } } /** * Sets the optional coverage database to use * * @param PhingFile The database file */ public function setDatabase(PhingFile $database) { $this->_database = $database; } /** * Create classpath object * * @return Path */ public function createClasspath() { $this->classpath = new Path(); return $this->classpath; } /** * Sets the coverage threshold for entire project * * @param integer $threshold Coverage threshold for entire project */ public function setPerProject($threshold) { $this->_perProject = $threshold; } /** * Sets the coverage threshold for any class * * @param integer $threshold Coverage threshold for any class */ public function setPerClass($threshold) { $this->_perClass = $threshold; } /** * Sets the coverage threshold for any method * * @param integer $threshold Coverage threshold for any method */ public function setPerMethod($threshold) { $this->_perMethod = $threshold; } /** * Sets whether to enable detailed logging or not * * @param boolean $verbose */ public function setVerbose($verbose) { $this->_verbose = StringHelper::booleanValue($verbose); } /** * Filter covered statements * * @param integer $var Coverage CODE/count * @return boolean */ protected function filterCovered($var) { return ($var >= 0 || $var === -2); } /** * Calculates the coverage threshold * * @param string $filename The filename to analyse * @param array $coverageInformation Array with coverage information */ protected function calculateCoverageThreshold($filename, $coverageInformation) { $classes = PHPUnitUtil::getDefinedClasses($filename, $this->_classpath); if (is_array($classes)) { foreach ($classes as $className) { $reflection = new ReflectionClass($className); $classStartLine = $reflection->getStartLine(); // Strange PHP5 reflection bug, classes without parent class // or implemented interfaces seem to start one line off if ($reflection->getParentClass() === null && count($reflection->getInterfaces()) === 0 ) { unset($coverageInformation[$classStartLine + 1]); } else { unset($coverageInformation[$classStartLine]); } reset($coverageInformation); $methods = $reflection->getMethods(); foreach ($methods as $method) { // PHP5 reflection considers methods of a parent class // to be part of a subclass, we don't if ($method->getDeclaringClass()->getName() != $reflection->getName()) { continue; } $methodStartLine = $method->getStartLine(); $methodEndLine = $method->getEndLine(); // small fix for XDEBUG_CC_UNUSED if (isset($coverageInformation[$methodStartLine])) { unset($coverageInformation[$methodStartLine]); } if (isset($coverageInformation[$methodEndLine])) { unset($coverageInformation[$methodEndLine]); } if ($method->isAbstract()) { continue; } $lineNr = key($coverageInformation); while ($lineNr !== null && $lineNr < $methodStartLine) { next($coverageInformation); $lineNr = key($coverageInformation); } $methodStatementsCovered = 0; $methodStatementCount = 0; while ($lineNr !== null && $lineNr <= $methodEndLine) { $methodStatementCount++; $lineCoverageInfo = $coverageInformation[$lineNr]; // set covered when CODE is other than -1 (not executed) if ($lineCoverageInfo > 0 || $lineCoverageInfo === -2) { $methodStatementsCovered++; } next($coverageInformation); $lineNr = key($coverageInformation); } if ($methodStatementCount > 0) { $methodCoverage = ( $methodStatementsCovered / $methodStatementCount) * 100; } else { $methodCoverage = 0; } if ($methodCoverage < $this->_perMethod && !$method->isAbstract() ) { throw new BuildException( 'The coverage (' . $methodCoverage . '%) ' . 'for method "' . $method->getName() . '" is lower' . ' than the specified threshold (' . $this->_perMethod . '%), see file: "' . $filename . '"' ); } elseif ($methodCoverage < $this->_perMethod && $method->isAbstract() ) { if ($this->_verbose === true) { $this->log( 'Skipped coverage threshold for abstract method "' . $method->getName() . '"' ); } } // store the minimum coverage value for logging (see #466) if ($this->_minMethodCoverageFound !== null) { if ($this->_minMethodCoverageFound > $methodCoverage) { $this->_minMethodCoverageFound = $methodCoverage; } } else { $this->_minMethodCoverageFound = $methodCoverage; } } $classStatementCount = count($coverageInformation); $classStatementsCovered = count( array_filter( $coverageInformation, array($this, 'filterCovered') ) ); if ($classStatementCount > 0) { $classCoverage = ( $classStatementsCovered / $classStatementCount) * 100; } else { $classCoverage = 0; } if ($classCoverage < $this->_perClass && !$reflection->isAbstract() ) { throw new BuildException( 'The coverage (' . $classCoverage . '%) for class "' . $reflection->getName() . '" is lower than the ' . 'specified threshold (' . $this->_perClass . '%), ' . 'see file: "' . $filename . '"' ); } elseif ($classCoverage < $this->_perClass && $reflection->isAbstract() ) { if ($this->_verbose === true) { $this->log( 'Skipped coverage threshold for abstract class "' . $reflection->getName() . '"' ); } } // store the minimum coverage value for logging (see #466) if ($this->_minClassCoverageFound !== null) { if ($this->_minClassCoverageFound > $classCoverage) { $this->_minClassCoverageFound = $classCoverage; } } else { $this->_minClassCoverageFound = $classCoverage; } $this->_projectStatementCount += $classStatementCount; $this->_projectStatementsCovered += $classStatementsCovered; } } } public function main() { if ($this->_database === null) { $coverageDatabase = $this->project ->getProperty('coverage.database'); if (! $coverageDatabase) { throw new BuildException( 'Either include coverage-setup in your build file or set ' . 'the "database" attribute' ); } $database = new PhingFile($coverageDatabase); } else { $database = $this->_database; } $this->log( 'Calculating coverage threshold: min. ' . $this->_perProject . '% per project, ' . $this->_perClass . '% per class and ' . $this->_perMethod . '% per method is required' ); $props = new Properties(); $props->load($database); foreach ($props->keys() as $filename) { $file = unserialize($props->getProperty($filename)); $this->calculateCoverageThreshold( $file['fullname'], $file['coverage'] ); } if ($this->_projectStatementCount > 0) { $coverage = ( $this->_projectStatementsCovered / $this->_projectStatementCount) * 100; } else { $coverage = 0; } if ($coverage < $this->_perProject) { throw new BuildException( 'The coverage (' . $coverage . '%) for the entire project ' . 'is lower than the specified threshold (' . $this->_perProject . '%)' ); } $this->log( 'Passed coverage threshold. Minimum found coverage values are: ' . round($coverage, 2) . '% per project, ' . round($this->_minClassCoverageFound, 2) . '% per class and ' . round($this->_minMethodCoverageFound, 2) . '% per method' ); } }