pekral / test-redundancy
Finds PHPUnit and Pest tests whose covered lines other tests already cover, and shows what each candidate asserts beyond them.
Requires
- php: ^8.4
- ext-libxml: *
- ext-simplexml: *
- ext-zlib: *
- nikic/php-parser: ^5.0
- phpunit/php-code-coverage: ^10.1 || ^11.0 || ^12.0 || ^13.0 || ^14.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/filesystem: ^6.4 || ^7.0 || ^8.0
- symfony/finder: ^6.4 || ^7.0 || ^8.0
- symfony/process: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.50.0
- laravel/pint: ^1.27.1
- pekral/ai-olympus: ^0.1.3
- pekral/phpcs-rules: ^0.4.8 || ^0.5
- pekral/phpstan-rules: ^0.4.5
- pekral/rector-rules: ^0.6
- pestphp/pest: ^3.0 || ^4.0 || ^5.0
- phpstan/phpstan-deprecation-rules: >=2.0.3
- phpstan/phpstan-phpunit: ^2.0.14
- roave/security-advisories: dev-latest
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 0.1.4
- 0.1.3
- 0.1.2
- 0.1.1
- 0.1
- dev-chore/release-0.1.4
- dev-fix/82-enum-value-array-key-call-path
- dev-fix/79-assertion-only-key-in-covering-differs-by
- dev-fix/enum-value-call-path
- dev-fix/merge-candidate-missing-argument-position
- dev-fix/key-token-path
- dev-fix/list-length-token
- dev-fix/merge-candidate-missing-covering-input
- dev-release/0.1.3
- dev-feat/skip-single-test-files-from-analyse
- dev-feat/limit-pcov-directory-to-source
- dev-feat/balance-worker-groups-by-duration
- dev-fix/assertion-subject-token
- dev-fix/assertion-free-test-review-60
- dev-fix/call-path-every-enclosing-call
- dev-fix/58-dataset-row-argument-positions
- dev-fix/fingerprint-closure-dataset-value
- dev-release/0.1.2
- dev-fix/51-bind-non-empty-lists
- dev-fix/review-candidate-missing-covering-call-argument
- dev-fix/46-missing-key-review
- dev-fix/45-delete-requires-matched-test-covering-lines
- dev-release/0.1.1
- dev-fix/support-digest-dot-files
- dev-fix/verify-support-change-instruction
- dev-fix/positional-dataset-row-differs-by
This package is auto-updated.
Last update: 2026-10-24 09:12:51 UTC
README
Fewer tests, the same coverage
test-redundancy finds the test cases in a PHPUnit or Pest suite whose covered lines other test cases
already cover, and tells you — or an AI agent — what each of them asserts beyond those tests. Point it at a test folder
or file. It runs the tests once with per-test coverage and returns a JSON list of candidates to delete, merge into a
dataset, or review, so the suite keeps 100% coverage with fewer and simpler tests.
Requirements
| Version | |
|---|---|
| PHP | ^8.4 |
phpunit/php-code-coverage |
^10.1, ^11, ^12, ^13 or ^14 — the one your PHPUnit or Pest installs |
| Coverage driver | pcov (the tool limits pcov.directory to the source directories) or xdebug |
| Test runner | vendor/bin/pest, or vendor/bin/phpunit with vendor/bin/paratest — the tool runs the tests only in parallel, so PHPUnit 13 needs 13.4+, the oldest version paratest supports |
Contributing to this repository needs PHP 8.4+ with pcov.
Installation
composer require --dev pekral/test-redundancy
Configuration
Optional. Without a file the tool reads src/ and app/, runs 2 parallel processes, and picks vendor/bin/pest
when it exists, otherwise vendor/bin/phpunit. To change that, add test-redundancy.json to the project root:
{
"source": ["app"],
"ignore": "#^app/Providers/#",
"env": { "TELESCOPE_ENABLED": "false" },
"processes": 6,
"map": "storage/test-redundancy/coverage-map.json.gz",
"runner": "vendor/bin/pest"
}
| Key | Default | Meaning |
|---|---|---|
source |
["src", "app"] |
Directories whose lines count. |
ignore |
none | A regex of source paths to leave out, e.g. code every test boots. |
env |
{} |
Environment variables for every test run. Concurrent groups also get TEST_TOKEN 1…N, as paratest workers do. |
processes |
2 |
Parallel processes, at least 2; --processes overrides it. The tool never runs the tests sequentially. |
map |
.test-redundancy/coverage-map.json.gz |
Where the map command stores the coverage map. |
runner |
auto | Path to pest or phpunit, relative to the project root or absolute. |
Usage
# A test folder or a single test file vendor/bin/test-redundancy tests/Unit/Billing vendor/bin/test-redundancy tests/Unit/Billing/InvoiceTest.php # No path: the test files the branch changes against origin/HEAD, or against --base vendor/bin/test-redundancy vendor/bin/test-redundancy --base=master # A report for people instead of JSON vendor/bin/test-redundancy tests/Unit --format=text # More or fewer parallel processes than the configuration says vendor/bin/test-redundancy tests/Unit --processes=8 # After changing the tests: fail when a source line the last analyse covered is no longer covered vendor/bin/test-redundancy verify
The JSON is compact; --pretty indents it.
The tests always run in parallel, on the test tokens 1…processes, so never start the tool next to another run of the
same suite. One folder or file runs in the runner's own parallel mode — pest --parallel, or paratest next to
phpunit — with the coverage settings passed to every worker through --passthru-php and the project's
phpunit.xml or phpunit.xml.dist passed through --configuration.
Several paths, such as the files a branch changes, are split into up to processes groups that run concurrently,
each with its own TEST_TOKEN. When a single folder holds test files that
analyse skips, the remaining files run as such groups too.
Every run stores the time of each test file from its JUnit log in
.test-redundancy/durations.json (next to the map). The next run puts the longest file first and every file into the
group with the smallest total, so no group collects the slowest files; a file without a stored time goes first. Without any
stored time the files are split round-robin. The split changes how long the run takes. For tests that share no state
inside one process it never changes the report or the baseline; a test whose coverage depends on what an earlier test
file in the same group already ran, such as a static cache, can cover other lines in another group.
Every run passes --cache-directory=.test-redundancy/cache, so the coverage analysis cache survives
between runs. vendor/bin/test-redundancy analyse --help lists every option.
The JSON report
This is the report for the Pest fixture in tests/Fixtures/pest, shortened to one candidate:
{
"files": [
{
"candidates": [
{
"action": "merge-into-dataset",
"coveredBy": ["it adds two numbers › negative numbers"],
"coveringDiffersBy": [],
"differsBy": ["in:call:add#0/value:0", "in:call:add#1/value:5", "in:expect:call:add/call:toBe#0/value:5"],
"steps": [
"Turn \"it adds two numbers › negative numbers\" into one test with a dataset and add a row with the values in differsBy.",
"Delete this test.",
"Run vendor/bin/test-redundancy verify; it must report \"ok\": true."
],
"test": "it adds zero",
"unionOnlyLines": []
}
],
"coveredLines": 4,
"file": "tests/CalculatorTest.php",
"testCases": 6,
"testsWithoutSourceLines": []
}
],
"summary": {
"byAction": { "delete": 0, "merge-into-dataset": 2, "review-dataset-row": 1, "review": 0 },
"candidates": 3,
"seconds": 0,
"skippedFiles": 1,
"staleMap": null,
"testCases": 7,
"testFiles": 2,
"testsWithoutSourceLines": 0
},
"tool": "pekral/test-redundancy",
"workflow": ["Goal: 100% coverage with the fewest and simplest tests. Coverage cannot see assertions, so judge every candidate.", "…"]
}
| Field | Meaning |
|---|---|
files |
Only the files with a candidate or a test without source lines. summary.testFiles counts every analysed file, and summary.testCases every test case, a skipped file included. |
testCases, coveredLines |
Test cases of the file, and the source lines they cover. |
coveredBy |
A small set of kept tests, picked greedily, that covers every line of the candidate. A test from another file carries its path. |
differsBy |
The tokens the candidate has, or has more times, and the closest covering test lacks — see How differsBy names a difference. null when the test or its dataset row cannot be parsed; the first step then says so. |
coveringDiffersBy |
The tokens of the covering test that made the action review although differsBy holds only data or is empty — see When a candidate is review, not delete. The review steps name them. Empty for every other candidate. |
unionOnlyLines |
The source lines of the candidate that the covering test with differsBy: [] does not run, because only the union of the coveredBy tests reaches them. The action is then review, and the steps say so. Empty for every other candidate. |
steps |
What to do with the candidate, naming every covering test, or the sibling dataset row. The last step always runs verify. |
action |
delete — nothing new; merge-into-dataset — differs only in data from one test of the same file; review-dataset-row — a row of the same dataset runs the same lines; review — asserts something the covering tests do not, differs from one test of the same file in more than data, or could not be parsed. When a candidate is review, not delete lists the rules for delete and merge-into-dataset. |
testsWithoutSourceLines |
Tests that run no line under source. They are never candidates; check what they test, or the source setting. |
summary.staleMap |
With --across: null, or the map commit and the source files changed since it was built. |
summary.seconds |
How long the analysis took. |
summary.skippedFiles |
The number of test files analyse did not run, because each declares one test case and no dataset — see Test files with a single test case. They count in summary.testFiles and summary.testCases as one test case each and have no candidate. |
workflow |
The rules an agent follows for the whole report. |
When a candidate is review, not delete
delete needs an empty differsBy and every one of these conditions. The tool checks them in this order: while
unionOnlyLines is not empty, coveringDiffersBy stays empty and the steps name only the lines and the covering tests.
- One test runs every line. One covering test runs every line of the candidate. When only the union of the
coveredBytests reaches some lines, they are listed inunionOnlyLinesand the action isreview. - No missing array key. The covering test sets no array key, or sets no key more times, that the candidate lacks.
A missing key is another input shape, so the
key:tokens are listed incoveringDiffersByand the action isreview. A key passed only to an assertion, such as a key of the arraytoEqualexpects, does not count, whether it is a top-level key of that array or a key nested in it. - No missing argument value. The covering test passes no value, or passes no value more times, to a call that is
not an assertion, such as an argument of the code under test (
in:call:getByListIdAndLastId#4/value:'email'). A missing argument value is another input, so the value is listed incoveringDiffersByand the action isreview. A value passed only to an assertion, such as a Pest expectation (toBe) or a PHPUnitassert*orexpect*call, does not count. - No missing arrangement for a test without an assertion. A candidate without an assertion asserts only that the
code under test throws no exception for its input. When the covering test has a call, function,
newexpression or value that the candidate lacks, such asfunction:createDomainfor a record it creates before the call under test, the candidate can be the only test of an empty or missing input. The tokens are listed incoveringDiffersBy, the action isreview, and the steps ask whether the empty or missing input is a case of its own. An assertion and a value or key passed only to an assertion do not count, so a covering test that only asserts more about the same input still leaves the candidatedelete.
merge-into-dataset needs a candidate that is not a dataset row, whose differsBy holds only data, and that differs
only in data from one test of the same file. When that test has a call, function or new expression that the
candidate lacks, or has it more times than the candidate does, the two tests cannot be rows of one test: the tokens are
listed in coveringDiffersBy and the action is review. When that test sets an array key that the candidate lacks,
such as in:call:post#1/key:value:'default_min_spend', a row with the values in differsBy would still set the key,
so the case without the key would be lost: the key: tokens are listed in coveringDiffersBy, the action is review,
and the steps ask whether the code under test reads one of these keys. A key nested in a value that differsBy
already lists does not count, because a row replaces the whole value. For example, the key 'color' in
store(['custom_fields' => ['color' => 'red']]) is nested in the value that differsBy lists as
in:function:store#0/value:'custom_fields'/value:[] for store(['custom_fields' => []]), so that candidate stays
merge-into-dataset. A key that test passes only to an assertion does not count either, because it is not input to
the code under test. When that test passes an argument at a position where differsBy lists no value, such as
in:function:store#1/value:2 for store(1, 2) against store(3), a row with the values in differsBy would still
pass the argument, so the case without it would be lost: the argument tokens are listed in coveringDiffersBy, the
action is review, and the steps ask whether the code under test behaves differently without one of these values. A
candidate whose differsBy holds only data against several covering tests or against a test
of another file is review too, and so is a dataset row whose covering test is not a row of the same dataset.
How differsBy names a difference
differsBy lists the literals, constants, functions, calls and new expressions the candidate has and the covering
test lacks.
- One covering test, not a union. With several covering tests,
differsByis the smallest difference against one of them. - Counts. The fingerprint counts each token, so a token the candidate uses more times than the covering test is listed too. The count is the number of places the token appears in the test source, not the number of runs.
- Loops. A call, function call or
newexpression in the body of aforeach,for,whileordoloop carries aloop:prefix, e.g.loop:call:Batch::import. A call made in a loop and the same call made outside a loop are different tokens, so the action isreview. Two loops differ only in the tokens they carry, never in the number of runs, and a callback passed to a function such asarray_map()is not a loop body. - Negation. A call made through Pest's
->notor->not()carries anot:prefix, e.g.not:call:toBeEmpty. - Property-form assertions. A Pest assertion written as a property, e.g.
->toBeEmptyor->not->toBeEmpty, carries the token of its call form. - Assertion subjects. An assertion carries the value it checks, its subject, as an
expect:prefix. The same assertion on two different subjects therefore gives two different tokens, and the candidate is notdelete.expect($templates->create())->toBeGreaterThan(0)has the tokensexpect:call:create/call:toBeGreaterThanandin:expect:call:create/call:toBeGreaterThan#0/value:0, andexpect($template->order)->toBeGreaterThan(0)hasexpect:property:order/call:toBeGreaterThanandin:expect:property:order/call:toBeGreaterThan#0/value:0.- The subject of a Pest expectation is the value passed to the nearest
->and()before the call, or else toexpect(). Every call of the expectation chain carries it,->and()and->eachincluded. - The subject of a PHPUnit
assert*call is its last argument that is not a literal or a constant:self::assertSame(3, add(1, 2))has the tokenexpect:function:add/call:self::assertSame. - A property carries the token
property:<name>. A call, a function call and anewexpression carry their own token. - A variable that the test assigns once in its own statements stands for the value assigned to it:
$sum = add(1, 2); expect($sum)->toBe(3);has the same assertion tokens asexpect(add(1, 2))->toBe(3). - A literal, a constant, any other variable, a closure and any other expression give no subject. The assertion then
keeps its token without the prefix, e.g.
call:toBe.
- The subject of a Pest expectation is the value passed to the nearest
- Array keys and values. An array key carries a
key:prefix and the path of its array, the same path a literal in the array has:key:value:'x'for$options = ['x' => 1]that the test never passes on,in:function:store#0/key:value:'x'forstore(['x' => 1]), andin:function:store#0/value:'user'/key:value:'x'forstore(['user' => ['x' => 1]]). An enum case read through->valueor->nameas a key names the property it reads, like an argument value:in:function:store#0/key:const:CouponType::FIXED->valueforstore([CouponType::FIXED->value => true]). A literal under an array key carries the key as a path prefix, e.g.in:value:'enabled'/value:falsefor$options = ['enabled' => false]that the test never passes on, so the same literal under another key, or in a list without a key, is a different token. A map passed to a call also carries the call and the argument position (see Call arguments). - Empty arrays. An empty array literal
[]carries the tokenvalue:[]. Under an array key it carries the key path too, e.g.in:value:'tags'/value:[]for['tags' => []]. - Lists. A non-empty array literal whose elements have no key, and none of which unpacks an array with
..., carries the tokenvalue:list-itemonce per element. The token has the path an element in the place of the list would have, e.g.in:function:notify#0/value:list-itemcounted three times fornotify([$click, $click, $click]). The length of a list therefore counts even when its elements carry no token, such as a variable whose later reads add no token (see Local variables): a test that passes the same item three times in one list lists the token indiffersByagainst a test that passes it once, and is notdelete. A shorter list adds nothing todiffersBy, because a token the candidate has fewer times is never listed. - Call arguments. A literal, constant or empty array passed straight to a call or a
newexpression carries the call and the argument position, e.g.in:function:store#0/value:trueandin:function:store#1/value:falseforstore(true, false), so the same literals at swapped positions are different tokens. A named argument carries its name instead of the position, e.g.in:function:store#enabled/value:true. An enum case read through->valueor->namenames the property it reads, e.g.in:function:store#0/const:CouponType::FIXED->valueforstore(CouponType::FIXED->value), so the value, the name and the case itself are different tokens. An element of a list passed straight to a call carries the same path, e.g.in:function:store#0/value:'a'forstore(['a'])andin:function:store#0/const:CouponType::FIXED->valueforstore([CouponType::FIXED->value]). A value under a key of an array passed straight to a call carries the call and the position before the key, e.g.in:function:store#0/value:'enabled'/value:falseforstore(['enabled' => false]), so the same array at swapped positions is a different token. - Enclosing calls. A value that an argument passes on through an array or a nested call carries every enclosing
call and array key, outermost first.
json_encode(json_encode(['test' => true]))has the tokenin:function:json_encode#0/function:json_encode#0/value:'test'/value:true, andjson_encode(['test' => true])hasin:function:json_encode#0/value:'test'/value:true, so a value passed through one more call is a different token. An assertion, such as a Pest expectation (expect,toBe) or a PHPUnitassert*orexpect*call, is left out of the path of a value that reaches it through another call:expect(add(1, 2))has the tokenin:function:add#0/value:1, the same as$sum = add(1, 2). An operand of an operator, a cast or a closure body keeps no call path:store(1 + 2)has the tokensvalue:1andvalue:2. A list item keeps the call path only as a direct item of an argument:store([1])has the tokenin:function:store#0/value:1, butstore([[1]])hasvalue:1andstore(['tags' => ['a']])hasin:value:'tags'/value:'a'. Sostore([[1]])does not get the token ofstore([1])orstore(1), andstore(['tags' => ['a']])does not get the token ofstore(['tags' => 'a']). A list passed straight to a call keeps the token of its element:store([1])has the token ofstore(1)and the token of the list (see Lists). - Dataset rows. A dataset row value is the value of the parameter at the same position of the test closure or
method. A literal, constant, empty array, list or map in a row is counted where the test reads that
parameter: the row
[1, 2, 3]ofexpect(add($first, $second))->toBe($sum)has the same tokens asexpect(add(1, 2))->toBe(3), the row[['id', 'tags']]ofload($columns)has the same tokens asload(['id', 'tags']), and the row[['id' => 5]]ofload($options)has the same tokens asload(['id' => 5]). A list is a non-empty array whose elements have no key and are each a literal, constant, empty array, list or map. A map is a non-empty array whose elements all have a key that is a literal or constant, and each value is a literal, constant, empty array, list or map, so lists and maps can nest. Any other row value, such asnew Contact()or['id' => $id], is counted once, as the row has it, however many times the test reads the parameter. A row value whose parameter the test never reads by its name keeps its token without a path. A closure or arrow function that is the row value, or one argument of the row, also carries the tokenvalue:closure:<hash>, a hash of its source asnikic/php-parserprints it. Two rows whose closures have different bodies, such asfn (string $email): string => $emailandfn (string $email): string => rawurlencode($email), therefore differ indiffersBy, and the row is notdelete. Two closures that differ only in whitespace or line breaks have the same token. A closure inside an array argument, such as['map' => fn ($x) => $x], carries only the tokens of its body. - Local variables. A literal, constant, empty array, list or map (as defined for dataset rows) that the test
assigns once to a local variable in its own statements is counted where the test reads the variable:
$email = 'a@example.com'; notify($email);has the same tokens asnotify('a@example.com'),$columns = ['id', 'tags']; load($columns);has the same tokens asload(['id', 'tags']), and$options = ['enabled' => false]; store($options);has the same tokens asstore(['enabled' => false]). A variable the test writes more than once keeps its tokens without a path. A call, function call ornewexpression that the test assigns once to a local variable is counted once, where the test first reads the variable, when that read passes the variable on as a call argument or an array value outside a loop body:$email = sprintf('email@%s', $domain); notify($email);has the same tokens asnotify(sprintf('email@%s', $domain)), and a later read of the variable adds no token. A variable whose first read does not pass it on, such as$user->email, or whose first read is in a loop body keeps the tokens of the call without a path.
Test files with a single test case
Without --across, analyse compares a test case only with the other test cases of the same file, so a file with one
test case can never hold a candidate. analyse therefore does not run such a file. It counts the test cases of each
file before the coverage run with the same nikic/php-parser fingerprints as the rest of the report, and skips a file
only when all of these hold:
- The file declares exactly one Pest
it()/test()or one PHPUnit test method. - No dataset reaches it: no
->with(), no->repeat(), no#[DataProvider],#[DataProviderExternal],#[TestWith],@dataProvideror@testWith. A named or shared dataset, a dataset the parser cannot resolve, and adescribe()block with a dataset all keep the file in the run, because the number of runs is known only at run time. - The parser resolves every
it()/test()call. A test that a loop generates, a test with a description that is not a string literal, two tests with the same description, and a file that does not parse keep the file in the run.
A skipped file stays in the report: it counts in summary.testFiles and summary.testCases as one test case, it is
counted in summary.skippedFiles, and it has no candidate, no coveredLines and no testsWithoutSourceLines, because
it did not run. The candidates of every other file are the same as when the file runs. --across and map run every
file, because there a single test case is compared with the test cases of other files.
Verifying that coverage stayed
analyse stores the source lines the analysed tests cover in .test-redundancy/baseline.json (next to the map). Add
.test-redundancy/ to .gitignore; the map, the baseline, the file durations and the runner cache are local state. After
the tests change, verify runs the same test files again and compares:
{"baselineLines":4,"instruction":"Every source line the analysed tests covered before is still covered.","lostLines":[],"missingTestFiles":[],"ok":true,"tool":"pekral/test-redundancy","unstableLines":[]}
A test file that analyse skipped stays in the baseline without lines, so verify runs it and reports its deletion in
missingTestFiles. verify cannot know the lines that only a deleted skipped file covered, because analyse never ran it.
It exits with 1 and lists every line in lostLines when a change dropped coverage. After --across, a line another
mapped test file still covers does not count as lost.
analyse also stores a hash of every analysed test file, of every covered source file and of the test support files.
The support files are the other files, not named *Test.php, in the top-level directory of a test file, such as
tests/: Pest datasets, tests/Pest.php, base test cases, traits and fixtures. When a line is no longer covered, none
of the test files that covered it changed, its source file did not change and no support file changed, verify lists it
in unstableLines instead of lostLines, exits with 0 and tells the agent not to restore a test. The coverage of such
a test depends on state outside the test run, such as a cache or a database. A line that a changed or deleted test file
also covered, a line of a source file that changed since analyse, and every lost line after a support file was
changed, added or deleted stay in lostLines, and verify exits with 1. When a changed support file is the reason and no
baseline test file is missing, the instruction tells the agent to check that file or to run analyse again, not to
restore a test. When a baseline test file is missing too, the instruction still tells the agent to restore the test and
adds that a changed support file may also be the reason. A baseline written before source file hashes
or support file hashes existed reports every lost line in lostLines; run analyse again to restore the distinction.
How to read a candidate
A candidate runs no line the other tests do not run. Coverage cannot see what a test asserts, so a candidate is not proof that a test is useless:
- Delete it only when a test under
coveredByasserts the same input and the same result. - Merge it into a dataset when the two differ only in data.
- Keep a test that asserts a different result, a different input class (empty, boundary, another tenant, invalid), an ordering, or a rejection. When in doubt, keep it.
The candidates of one file can be removed together without losing a covered line. Re-run the tool after each change.
Comparing across files
# Per-test coverage of the given tests (default: tests) vendor/bin/test-redundancy map tests # Compare a folder or a file with every mapped test vendor/bin/test-redundancy tests/Unit/Billing --across
--across reads unchanged test files from the map and re-runs only the changed ones. It warns when source files changed
since the map was built. Of two tests that cover each other, it proposes at most one.
How it works
- One test run with
--coverage-phpand--log-junitrecords which test case — every dataset row separately — covers which source line. Test descriptions come from the JUnit log. - A greedy pass removes test cases whose lines the remaining test cases still cover. On a tie it removes standalone tests before dataset rows.
- For each candidate it picks a small covering set greedily and compares contract fingerprints built with
nikic/php-parser: Pestit()/test()with->with()datasets and chained modifiers such as->throws(), and PHPUnittest*/#[Test]/@testmethods with#[DataProvider]/@dataProviderrows. Pest tests inside a namespace ordescribe()blocks, and shareddataset('name', …)rows of the same file, are parsed too.
Testing
composer install
composer test
| Command | What it does |
|---|---|
composer check |
Composer normalize, PHPCS, Pint, Rector, PHPStan, security audit, and the tests with 100% coverage |
composer fix |
Composer normalize, Rector, Pint and PHPCS fixes |
composer test |
The tests without coverage |
composer test:coverage |
The tests with a 100% coverage minimum |
composer ai-olympus:install |
Reinstall the pekral/ai-olympus agent rules and skills |
PHPCS, PHPStan and Rector use pekral/phpcs-rules, pekral/phpstan-rules and pekral/rector-rules.
CI
Pull requests and pushes to master run composer check on PHP 8.4 and 8.5, then the tool on its own tests. A matrix
runs the tests on PHP 8.4 and 8.5 with the lowest and the highest dependencies, and a second matrix runs the tool on
the PHPUnit fixture with PHPUnit 10.5, 11.5, 12.5 and 13.4 with paratest and no Pest, on PHP 8.4. A security audit runs every
Monday, and Dependabot proposes Composer and GitHub Actions updates weekly.
Changelog
See CHANGELOG.
Contributing
See CONTRIBUTING.
Security
See the security policy.
License
MIT — see LICENSE.