diff --git a/.gitattributes b/.gitattributes index 22aac70..8c85471 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,7 +4,6 @@ /LICENSE export-ignore /Makefile export-ignore /README.md export-ignore -/phpmd.xml export-ignore /phpunit.xml export-ignore /phpstan.neon.dist export-ignore /infection.json.dist export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0ce8fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 + +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "php" + - "security" + - "dependencies" + groups: + php-security: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "build" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index a17e92e..f37e91f 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -18,7 +18,7 @@ jobs: - name: Assign issues and pull requests uses: gustavofreze/auto-assign@2.0.0 with: - assignees: '${{ secrets.ASSIGNEES }}' + assignees: '${{ vars.ASSIGNEES }}' github_token: '${{ secrets.GITHUB_TOKEN }}' allow_self_assign: 'true' allow_no_assignees: 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 358ac92..e898dc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,24 +7,59 @@ permissions: contents: read env: - PHP_VERSION: '8.3' + PHP_VERSION: '8.5' jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: bcmath + tools: composer:2 + + - name: Validate composer.json + run: composer validate --no-interaction + + - name: Install dependencies + run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + + - name: Upload vendor and composer.lock as artifact + uses: actions/upload-artifact@v6 + with: + name: vendor-artifact + path: | + vendor + composer.lock + auto-review: name: Auto review runs-on: ubuntu-latest + needs: build steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Configure PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} + extensions: bcmath + tools: composer:2 - - name: Install dependencies - run: composer update --no-progress --optimize-autoloader + - name: Download vendor artifact from build + uses: actions/download-artifact@v7 + with: + name: vendor-artifact + path: . - name: Run review run: composer review @@ -32,18 +67,24 @@ jobs: tests: name: Tests runs-on: ubuntu-latest + needs: auto-review steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Use PHP ${{ env.PHP_VERSION }} + - name: Configure PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} + extensions: bcmath + tools: composer:2 - - name: Install dependencies - run: composer update --no-progress --optimize-autoloader + - name: Download vendor artifact from build + uses: actions/download-artifact@v7 + with: + name: vendor-artifact + path: . - name: Run tests run: composer tests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c6d7f7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: Security checks + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "0 0 * * *" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ "actions" ] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore index 1cb9b22..42b841a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .idea + vendor report -.phpunit.cache +.phpunit.* -composer.lock -.phpunit.result.cache \ No newline at end of file +*.lock diff --git a/Makefile b/Makefile index 96ccd27..ef9a884 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,4 @@ -ifeq ($(OS),Windows_NT) - PWD := $(shell cd) -else - PWD := $(shell pwd -L) -endif - +PWD := $(CURDIR) ARCH := $(shell uname -m) PLATFORM := @@ -11,28 +6,63 @@ ifeq ($(ARCH),arm64) PLATFORM := --platform=linux/amd64 endif -DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.3 +DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine -.PHONY: configure test test-file test-no-coverage review show-reports clean +RESET := \033[0m +GREEN := \033[0;32m +YELLOW := \033[0;33m -configure: +.DEFAULT_GOAL := help + +.PHONY: configure +configure: ## Configure development environment @${DOCKER_RUN} composer update --optimize-autoloader -test: +.PHONY: test +test: ## Run all tests with coverage @${DOCKER_RUN} composer tests -test-file: +.PHONY: test-file +test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file) @${DOCKER_RUN} composer test-file ${FILE} -test-no-coverage: +.PHONY: test-no-coverage +test-no-coverage: ## Run all tests without coverage @${DOCKER_RUN} composer tests-no-coverage -review: +.PHONY: review +review: ## Run static code analysis @${DOCKER_RUN} composer review -show-reports: +.PHONY: show-reports +show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html -clean: +.PHONY: clean +clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} @rm -rf report vendor .phpunit.cache *.lock + +.PHONY: help +help: ## Display this help message + @echo "Usage: make [target]" + @echo "" + @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" + @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" + @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" + @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" + @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" + @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' diff --git a/composer.json b/composer.json index da65250..0af7298 100644 --- a/composer.json +++ b/composer.json @@ -43,30 +43,27 @@ } }, "require": { - "php": "^8.3", + "php": "^8.5", "ext-bcmath": "*" }, "require-dev": { - "phpmd/phpmd": "^2.15", - "phpunit/phpunit": "^11", - "phpstan/phpstan": "^1", - "infection/infection": "^0", - "squizlabs/php_codesniffer": "^3.11" + "phpunit/phpunit": "^11.5", + "phpstan/phpstan": "^2.1", + "infection/infection": "^0.32", + "squizlabs/php_codesniffer": "^4.0" }, "suggest": { "ext-bcmath": "Enables the extension which is an interface to the GNU implementation as a Basic Calculator utility library." }, "scripts": { - "test": "phpunit --configuration phpunit.xml tests", - "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", - "phpmd": "phpmd ./src text phpmd.xml --suffixes php --ignore-violations-on-exit", - "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress", - "test-file": "phpunit --configuration phpunit.xml --no-coverage --filter", - "mutation-test": "infection --only-covered --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "test-no-coverage": "phpunit --configuration phpunit.xml --no-coverage tests", + "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", + "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", + "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", "review": [ "@phpcs", - "@phpmd", "@phpstan" ], "tests": [ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e2ccbfc..3e20b2a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,6 +6,7 @@ parameters: ignoreErrors: - '#does not accept#' - '#Binary operation#' + - '#should return numeric-string#' - '#Access to an undefined property#' - '#function number_format expects int#' - '#expects TinyBlocks\\Math\\BigNumber#' diff --git a/src/BigNumber.php b/src/BigNumber.php index ad3b972..7e2ed38 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -139,7 +139,7 @@ public function toFloat(): float; /** * Converts the current BigNumber to a string representation. * - * @return string The string representation of the BigNumber. + * @return numeric-string The string representation of the BigNumber. */ public function toString(): string; diff --git a/src/Internal/Exceptions/DivisionByZero.php b/src/Internal/Exceptions/DivisionByZero.php index 0b4d0e9..c5d2ac6 100644 --- a/src/Internal/Exceptions/DivisionByZero.php +++ b/src/Internal/Exceptions/DivisionByZero.php @@ -8,10 +8,10 @@ final class DivisionByZero extends RuntimeException { - public function __construct(string $dividend, string $divisor) + public function __construct(private readonly string $dividend, private readonly string $divisor) { $template = 'Cannot divide <%.2f> by <%.2f>.'; - parent::__construct(message: sprintf($template, $dividend, $divisor)); + parent::__construct(message: sprintf($template, $this->dividend, $this->divisor)); } } diff --git a/src/Internal/Exceptions/InvalidNumber.php b/src/Internal/Exceptions/InvalidNumber.php index 173541a..81a7969 100644 --- a/src/Internal/Exceptions/InvalidNumber.php +++ b/src/Internal/Exceptions/InvalidNumber.php @@ -8,10 +8,10 @@ final class InvalidNumber extends RuntimeException { - public function __construct(string $value) + public function __construct(private readonly string $value) { $template = 'The value <%s> is not a valid number.'; - parent::__construct(message: sprintf($template, $value)); + parent::__construct(message: sprintf($template, $this->value)); } } diff --git a/src/Internal/Exceptions/InvalidScale.php b/src/Internal/Exceptions/InvalidScale.php index 85f3070..f1a3a3d 100644 --- a/src/Internal/Exceptions/InvalidScale.php +++ b/src/Internal/Exceptions/InvalidScale.php @@ -8,10 +8,13 @@ final class InvalidScale extends RuntimeException { - public function __construct(int $value, int $minimum, int $maximum) - { + public function __construct( + private readonly int $value, + private readonly int $minimum, + private readonly int $maximum + ) { $template = 'Scale value <%s> is invalid. The value must be between <%s> and <%s>.'; - parent::__construct(message: sprintf($template, $value, $minimum, $maximum)); + parent::__construct(message: sprintf($template, $this->value, $this->minimum, $this->maximum)); } } diff --git a/src/Internal/Exceptions/MathOperationsNotAvailable.php b/src/Internal/Exceptions/MathOperationsNotAvailable.php index 8a47fc9..40257e8 100644 --- a/src/Internal/Exceptions/MathOperationsNotAvailable.php +++ b/src/Internal/Exceptions/MathOperationsNotAvailable.php @@ -8,10 +8,10 @@ final class MathOperationsNotAvailable extends RuntimeException { - public function __construct(array $extensions) + public function __construct(private readonly array $extensions) { $template = 'There are no implementations available. Enable one of these <%s> extensions.'; - parent::__construct(message: sprintf($template, implode(',', $extensions))); + parent::__construct(message: sprintf($template, implode(',', $this->extensions))); } } diff --git a/src/Internal/Exceptions/NonNegativeValue.php b/src/Internal/Exceptions/NonNegativeValue.php index 06b1458..3304b39 100644 --- a/src/Internal/Exceptions/NonNegativeValue.php +++ b/src/Internal/Exceptions/NonNegativeValue.php @@ -9,10 +9,10 @@ final class NonNegativeValue extends RuntimeException { - public function __construct(Number $number) + public function __construct(private readonly Number $number) { $template = 'Value <%s> is not valid. Must be a negative number less than zero.'; - parent::__construct(message: sprintf($template, $number->value)); + parent::__construct(message: sprintf($template, $this->number->value)); } } diff --git a/src/Internal/Exceptions/NonPositiveValue.php b/src/Internal/Exceptions/NonPositiveValue.php index f20ffa5..ad3446f 100644 --- a/src/Internal/Exceptions/NonPositiveValue.php +++ b/src/Internal/Exceptions/NonPositiveValue.php @@ -9,10 +9,10 @@ final class NonPositiveValue extends RuntimeException { - public function __construct(Number $number) + public function __construct(private readonly Number $number) { $template = 'Value <%s> is not valid. Must be a positive number greater than zero.'; - parent::__construct(message: sprintf($template, $number->value)); + parent::__construct(message: sprintf($template, $this->number->value)); } } diff --git a/src/Internal/Number.php b/src/Internal/Number.php index 7371d09..758eeeb 100644 --- a/src/Internal/Number.php +++ b/src/Internal/Number.php @@ -44,6 +44,10 @@ private function __construct(public readonly string $value) public static function from(float|string $value): Number { + if (is_float($value) && is_nan($value)) { + throw new InvalidNumber(value: "NAN"); + } + return new Number(value: (string)$value); } diff --git a/src/Internal/Operations/Adapters/BcMathAdapter.php b/src/Internal/Operations/Adapters/BcMathAdapter.php index 2aac4a7..d8b8186 100644 --- a/src/Internal/Operations/Adapters/BcMathAdapter.php +++ b/src/Internal/Operations/Adapters/BcMathAdapter.php @@ -16,7 +16,7 @@ { public function add(BigNumber $augend, BigNumber $addend): Result { - $scale = (new Addition(augend: $augend, addend: $addend))->applyScale(); + $scale = new Addition(augend: $augend, addend: $addend)->applyScale(); $number = Number::from( value: bcadd( $augend->toString(), @@ -30,7 +30,7 @@ public function add(BigNumber $augend, BigNumber $addend): Result public function subtract(BigNumber $minuend, BigNumber $subtrahend): Result { - $scale = (new Subtraction(minuend: $minuend, subtrahend: $subtrahend))->applyScale(); + $scale = new Subtraction(minuend: $minuend, subtrahend: $subtrahend)->applyScale(); $number = Number::from( value: bcsub( $minuend->toString(), @@ -44,7 +44,7 @@ public function subtract(BigNumber $minuend, BigNumber $subtrahend): Result public function multiply(BigNumber $multiplicand, BigNumber $multiplier): Result { - $scale = (new Multiplication(multiplicand: $multiplicand, multiplier: $multiplier))->applyScale(); + $scale = new Multiplication(multiplicand: $multiplicand, multiplier: $multiplier)->applyScale(); $number = Number::from( value: bcmul( $multiplicand->toString(), @@ -58,7 +58,7 @@ public function multiply(BigNumber $multiplicand, BigNumber $multiplier): Result public function divide(BigNumber $dividend, BigNumber $divisor): Result { - $scale = (new Division(dividend: $dividend, divisor: $divisor))->applyScale(); + $scale = new Division(dividend: $dividend, divisor: $divisor)->applyScale(); $number = Number::from( value: bcdiv( $dividend->toString(), diff --git a/src/Internal/Operations/Adapters/Scales/Addition.php b/src/Internal/Operations/Adapters/Scales/Addition.php index 57da81b..6a026de 100644 --- a/src/Internal/Operations/Adapters/Scales/Addition.php +++ b/src/Internal/Operations/Adapters/Scales/Addition.php @@ -25,10 +25,6 @@ public function applyScale(): Scale $augendScale = $this->augendScale->scaleOf(value: $this->augend->toString()); $addendScale = $this->addendScale->scaleOf(value: $this->addend->toString()); - if ($augendScale->equals(other: $addendScale)) { - return $augendScale; - } - return $augendScale->greaterScale(other: $addendScale); } diff --git a/src/Internal/Operations/Adapters/Scales/Subtraction.php b/src/Internal/Operations/Adapters/Scales/Subtraction.php index 618a1c7..27d093a 100644 --- a/src/Internal/Operations/Adapters/Scales/Subtraction.php +++ b/src/Internal/Operations/Adapters/Scales/Subtraction.php @@ -23,11 +23,7 @@ public function applyScale(): Scale { if ($this->minuendScale->hasAutomaticScale() && $this->subtrahendScale->hasAutomaticScale()) { $minuendScale = $this->minuendScale->scaleOf(value: $this->minuend->toString()); - $subtrahendScale = $this->minuendScale->scaleOf(value: $this->subtrahend->toString()); - - if ($minuendScale->equals(other: $subtrahendScale)) { - return $minuendScale; - } + $subtrahendScale = $this->subtrahendScale->scaleOf(value: $this->subtrahend->toString()); return $minuendScale->greaterScale(other: $subtrahendScale); } diff --git a/tests/Internal/NumberTest.php b/tests/Internal/NumberTest.php index cb271f5..da0d3b3 100644 --- a/tests/Internal/NumberTest.php +++ b/tests/Internal/NumberTest.php @@ -24,6 +24,20 @@ public function testInvalidNumber(mixed $value): void Number::from(value: $value); } + public function testInvalidNumberWhenValueIsNaN(): void + { + /** @Given a Not a Number (NaN) value */ + $value = NAN; + $template = 'The value is not a valid number.'; + + /** @Then an InvalidNumber exception should be thrown */ + $this->expectException(InvalidNumber::class); + $this->expectExceptionMessage($template); + + /** @When attempting to create a Number instance with the NaN value */ + Number::from(value: $value); + } + public static function invalidNumberDataProvider(): array { return [ @@ -32,7 +46,6 @@ public static function invalidNumberDataProvider(): array 'String "null"' => ['value' => 'null'], 'String "true"' => ['value' => 'true'], 'String "false"' => ['value' => 'false'], - 'Not a Number (NaN)' => ['value' => NAN], 'Positive infinity' => ['value' => INF], 'Negative infinity' => ['value' => -INF], 'Zero followed by x' => ['value' => '0x'], diff --git a/tests/Internal/Operations/MathOperationsFactoryTest.php b/tests/Internal/Operations/MathOperationsFactoryTest.php index 416e877..f375a8f 100644 --- a/tests/Internal/Operations/MathOperationsFactoryTest.php +++ b/tests/Internal/Operations/MathOperationsFactoryTest.php @@ -17,6 +17,6 @@ public function testMathOperationsNotAvailable(): void $this->expectException(MathOperationsNotAvailable::class); $this->expectExceptionMessage(sprintf($template, 'bcmath')); - (new MathOperationsFactory(extension: new ExtensionAdapterMock()))->build(); + new MathOperationsFactory(extension: new ExtensionAdapterMock())->build(); } }