Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file.

- Required Windows and macOS CI coverage at the oldest and newest supported PHP/Laravel boundaries, complementing the complete Linux version matrix.
- Native-filesystem regressions for paths with spaces, wildcard/segment excludes, baseline replacement, absolute Artisan paths, and separator-stable fingerprints.
- Positive and negative executable coverage contracts for every public non-diagnostic Transaction Guard rule.
- Exhaustive scanner regressions for every finite cache, RateLimiter, Redis, query-builder, Eloquent and relation operation catalog entry.
- Dedicated integrity regressions for analyzer diagnostics `TG900`, `TG901`, `TG902` and `TG903`.

## [v0.5.0] - 2026-08-24

Expand Down
29 changes: 27 additions & 2 deletions docs/SCENARIO-MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ The executable source of truth is [`tests/Support/ScenarioMatrix.php`](../tests/

The executable matrix is the source of truth for the exact scenario count. It is split into a core matrix plus focused hardening modules and covers these groups:

## Completeness contract

The PHP input space is unbounded, so no finite test suite can truthfully enumerate every possible Laravel program. Transaction Guard instead makes the finite public analyzer contract exhaustive and keeps broad behavioral scenarios around that contract.

The required test suite enforces all of the following:

- every public non-diagnostic `TG` rule in `RuleCatalog` has at least one positive scenario that must report it;
- every public non-diagnostic `TG` rule has at least one negative/control scenario that must not report it;
- scenario expectations may only reference canonical rule IDs and may not require and forbid the same rule simultaneously;
- analyzer integrity diagnostics `TG900`, `TG901`, `TG902` and `TG903` each have dedicated executable regressions;
- every finite cache mutation, cache-lock terminal and RateLimiter mutation in `OperationCatalog` is exercised through the scanner;
- every finite Redis mutation and mutating command is exercised, every listed Redis read is required to remain clean, every script command is conservatively reported, and control wrappers are verified not to become mutations by themselves;
- Redis method/command mutation catalogs must remain equivalent, duplicate-free and disjoint from read/script classifications;
- every finite query-builder, Eloquent static, Eloquent instance and Eloquent relation mutation catalog entry is exercised through cross-connection `TG021` analysis;
- adding a new public finding rule or finite catalog entry without matching test coverage therefore fails CI instead of silently reducing coverage.

The broad scenario count remains useful as a regression signal, but the count itself is deliberately not the completeness guarantee.

## Platform compatibility

The required CI gate validates Linux, Windows and macOS rather than assuming Linux filesystem semantics are portable.
Expand All @@ -12,7 +30,8 @@ The required CI gate validates Linux, Windows and macOS rather than assuming Lin
- Windows runs both boundary stacks: PHP 8.2 / Laravel 12 and PHP 8.5 / Laravel 13;
- macOS runs both boundary stacks: PHP 8.2 / Laravel 12 and PHP 8.5 / Laravel 13;
- the Windows/macOS jobs run the complete `composer check:all` gate, including Composer validation/audit, optimized autoloading, Pint, PHPStan, documentation checks, the dependency-free scenario matrix, benchmark bootstrap smoke and Pest;
- native-filesystem regressions cover paths containing spaces, Windows/Unix separator-stable fingerprints, segment and wildcard excludes, replacement of an existing baseline and absolute Artisan scan paths.
- native-filesystem regressions cover paths containing spaces, Windows/Unix separator-stable fingerprints, segment and wildcard excludes, replacement of an existing baseline and absolute Artisan scan paths;
- repository text files are normalized to LF through `.gitattributes`, preventing Windows checkout settings from changing formatter results.

All platform jobs are dependencies of `Tests / Required`, so a platform regression blocks merging.

Expand Down Expand Up @@ -77,7 +96,9 @@ All platform jobs are dependencies of `Tests / Required`, so a platform regressi
- Laravel filesystem mutations including streams/directory operations;
- native filesystem mutations;
- modern cache writes/invalidation (`putMany`, `remember*`, `flexible`, etc.);
- all finite catalogued cache and RateLimiter mutations plus representative read-only controls;
- Redis mutations, commands, increments, publishes, pipelines/transactions;
- all finite catalogued Redis mutations/read/script classifications;
- modern Redis write commands such as `DELEX`, `HGETDEL`, `HSETEX`, `XDELEX` and `XACKDEL`;
- known Redis read-only methods that should remain clean;
- unknown methods on proven Redis receivers reported conservatively rather than silently ignored;
Expand Down Expand Up @@ -117,6 +138,10 @@ All platform jobs are dependencies of `Tests / Required`, so a platform regressi
- suppression isolation;
- disabled rules;
- baseline filtering;
- read-only cache and Redis controls;
- resolved transaction callbacks that must not emit `TG014`;
- normal after-commit jobs that must not emit `TG022`/`TG023`;
- non-matching custom side-effect patterns;
- unreadable files and parse errors.

The dependency-free smoke runner executes the same scanner pipeline as the Pest suite:
Expand All @@ -131,4 +156,4 @@ The fast benchmark bootstrap smoke is part of `composer check`, while the full i
composer benchmark
```

Pest/Testbench additionally tests command registration, exit codes, output modes, recursive discovery, excludes, baseline persistence, cross-file metadata, namespace-context isolation and Redis classification refinements.
Pest/Testbench additionally tests command registration, exit codes, output modes, recursive discovery, excludes, baseline persistence, cross-file metadata, namespace-context isolation, operation-catalog completeness, analyzer diagnostics and Redis classification refinements.
78 changes: 78 additions & 0 deletions tests/Feature/AnalyzerDiagnosticCoverageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

use Codegenie\TransactionGuard\Analysis\AnalysisConfig;
use Codegenie\TransactionGuard\Analysis\ClassMetadataIndex;
use Codegenie\TransactionGuard\Analysis\SourceScanner;
use Codegenie\TransactionGuard\Tests\Support\CatalogScanner;
use Codegenie\TransactionGuard\TransactionGuard;

it('reports TG900 when a requested source file cannot be read', function (): void {
$missing = sys_get_temp_dir().DIRECTORY_SEPARATOR.'transaction-guard-missing-'.bin2hex(random_bytes(4)).'.php';
$scanner = new SourceScanner(ClassMetadataIndex::fromFiles([]));
$findings = $scanner->scan($missing);

expect($findings)->toHaveCount(1)
->and($findings[0]->rule)->toBe('TG900');
});

it('reports TG901 for invalid PHP syntax', function (): void {
$findings = CatalogScanner::scan('<?php function broken( {');

expect($findings)->toHaveCount(1)
->and($findings[0]->rule)->toBe('TG901');
});

it('reports TG902 when a valid analyzer regex fails at runtime', function (): void {
$originalLimit = ini_get('pcre.backtrack_limit');
ini_set('pcre.backtrack_limit', '1');
$tail = str_repeat('a', 128).'Y';
$source = "<?php\nuse Illuminate\\Support\\Facades\\DB;\nDB::transaction(function () { CustomGateway::noop(); });\n/* {$tail} */\n";

try {
$findings = CatalogScanner::scan($source, new AnalysisConfig(
customSideEffectPatterns: ['/(a+)+X/'],
));
} finally {
if (is_string($originalLimit)) {
ini_set('pcre.backtrack_limit', $originalLimit);
}
}

$rules = array_map(static fn ($finding): string => $finding->rule, $findings);
expect($rules)->toContain('TG902');
});

it('reports TG903 when a requested source subtree cannot be traversed', function (): void {
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('POSIX permission semantics are required for deterministic unreadable-directory coverage.');
}

$root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'transaction-guard-unreadable-'.bin2hex(random_bytes(4));
$blocked = $root.DIRECTORY_SEPARATOR.'blocked';
$hidden = $blocked.DIRECTORY_SEPARATOR.'Hidden.php';
mkdir($blocked, 0777, true);
file_put_contents($hidden, '<?php');
chmod($blocked, 0000);
Comment thread
jordyloeuille marked this conversation as resolved.

if (is_readable($blocked)) {
@chmod($blocked, 0777);
@unlink($hidden);
@rmdir($blocked);
@rmdir($root);
$this->markTestSkipped('The current process can bypass directory permission restrictions.');
}

try {
$result = (new TransactionGuard(new AnalysisConfig))->analyze([$root]);
$rules = array_map(static fn ($finding): string => $finding->rule, $result->diagnostics);

expect($rules)->toContain('TG903');
} finally {
@chmod($blocked, 0777);
@unlink($hidden);
@rmdir($blocked);
@rmdir($root);
}
});
72 changes: 72 additions & 0 deletions tests/Feature/CacheOperationCatalogCoverageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

use Codegenie\TransactionGuard\Analysis\OperationCatalog;
use Codegenie\TransactionGuard\Tests\Support\CatalogScanner;

it('detects every catalogued cache mutation', function (): void {
$statements = array_map(
static fn (string $method): string => "Cache::{$method}('key', 'value');",
OperationCatalog::CACHE_MUTATIONS,
);
$source = "<?php\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\DB;\nDB::transaction(function () {\n".implode("\n", $statements)."\n});\n";
$snippets = array_map(
static fn ($finding): string => $finding->snippet,
array_values(array_filter(CatalogScanner::scan($source), static fn ($finding): bool => $finding->rule === 'TG008')),
);
$joined = implode("\n", $snippets);

foreach (OperationCatalog::CACHE_MUTATIONS as $method) {
expect($joined, $method)->toContain("Cache::{$method}(");
}
});

it('detects every catalogued cache lock terminal', function (): void {
$statements = array_map(
static fn (string $method): string => "Cache::lock('key')->{$method}('value');",
OperationCatalog::CACHE_LOCK_TERMINALS,
);
$source = "<?php\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\DB;\nDB::transaction(function () {\n".implode("\n", $statements)."\n});\n";
$joined = implode("\n", array_map(
static fn ($finding): string => $finding->snippet,
array_values(array_filter(CatalogScanner::scan($source), static fn ($finding): bool => $finding->rule === 'TG008')),
));

foreach (OperationCatalog::CACHE_LOCK_TERMINALS as $method) {
expect($joined, $method)->toContain("->{$method}(");
}
});

it('detects every catalogued rate limiter mutation', function (): void {
$statements = array_map(
static fn (string $method): string => "RateLimiter::{$method}('key', 'value');",
OperationCatalog::RATE_LIMITER_MUTATIONS,
);
$source = "<?php\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nDB::transaction(function () {\n".implode("\n", $statements)."\n});\n";
$joined = implode("\n", array_map(
static fn ($finding): string => $finding->snippet,
array_values(array_filter(CatalogScanner::scan($source), static fn ($finding): bool => $finding->rule === 'TG008')),
));

foreach (OperationCatalog::RATE_LIMITER_MUTATIONS as $method) {
expect($joined, $method)->toContain("RateLimiter::{$method}(");
}
});

it('keeps representative cache reads clean', function (): void {
$source = <<<'PHP'
<?php
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
Cache::get('key');
Cache::has('key');
Cache::many(['a', 'b']);
});
PHP;

$rules = array_map(static fn ($finding): string => $finding->rule, CatalogScanner::scan($source));

expect($rules)->not->toContain('TG008');
});
83 changes: 83 additions & 0 deletions tests/Feature/DatabaseOperationCatalogCoverageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

use Codegenie\TransactionGuard\Analysis\AnalysisConfig;
use Codegenie\TransactionGuard\Analysis\OperationCatalog;
use Codegenie\TransactionGuard\Tests\Support\CatalogScanner;

it('detects every catalogued cross-connection query mutation', function (): void {
$statements = array_map(
static fn (string $method): string => "DB::connection('pgsql')->table('audit')->{$method}(['value' => 1]);",
OperationCatalog::QUERY_MUTATIONS,
);
$source = "<?php\nuse Illuminate\\Support\\Facades\\DB;\nDB::connection('mysql')->transaction(function () {\n".implode("\n", $statements)."\n});\n";
$joined = implode("\n", array_map(
static fn ($finding): string => $finding->snippet,
array_values(array_filter(
CatalogScanner::scan($source, new AnalysisConfig(defaultDatabaseConnection: 'mysql')),
static fn ($finding): bool => $finding->rule === 'TG021',
)),
));

foreach (OperationCatalog::QUERY_MUTATIONS as $method) {
expect($joined, $method)->toContain("->{$method}(");
}
});

it('detects every catalogued cross-connection Eloquent static mutation', function (): void {
$statements = array_map(
static fn (string $method): string => "Audit::{$method}(['value' => 1]);",
OperationCatalog::ELOQUENT_STATIC_MUTATIONS,
);
$source = "<?php\nnamespace App\\Models;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Facades\\DB;\nclass Audit extends Model { protected \$connection = 'pgsql'; }\nDB::connection('mysql')->transaction(function () {\n".implode("\n", $statements)."\n});\n";
$joined = implode("\n", array_map(
static fn ($finding): string => $finding->snippet,
array_values(array_filter(
CatalogScanner::scan($source, new AnalysisConfig(defaultDatabaseConnection: 'mysql')),
static fn ($finding): bool => $finding->rule === 'TG021',
)),
));

foreach (OperationCatalog::ELOQUENT_STATIC_MUTATIONS as $method) {
expect($joined, $method)->toContain("Audit::{$method}(");
}
});

it('detects every catalogued cross-connection Eloquent instance mutation', function (): void {
$statements = array_map(
static fn (string $method): string => "\$audit->{$method}(['value' => 1]);",
OperationCatalog::ELOQUENT_INSTANCE_MUTATIONS,
);
$source = "<?php\nnamespace App\\Models;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Facades\\DB;\nclass Audit extends Model { protected \$connection = 'pgsql'; }\nDB::connection('mysql')->transaction(function () {\n\$audit = new Audit;\n".implode("\n", $statements)."\n});\n";
$joined = implode("\n", array_map(
static fn ($finding): string => $finding->snippet,
array_values(array_filter(
CatalogScanner::scan($source, new AnalysisConfig(defaultDatabaseConnection: 'mysql')),
static fn ($finding): bool => $finding->rule === 'TG021',
)),
));

foreach (OperationCatalog::ELOQUENT_INSTANCE_MUTATIONS as $method) {
expect($joined, $method)->toContain("->{$method}(");
}
});

it('detects every catalogued cross-connection relation mutation', function (): void {
$statements = array_map(
static fn (string $method): string => "\$user->roles()->{$method}(['value' => 1]);",
OperationCatalog::RELATION_MUTATIONS,
);
$source = "<?php\nnamespace App\\Models;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Facades\\DB;\nclass Role extends Model { protected \$connection = 'pgsql'; }\nclass User extends Model { protected \$connection = 'mysql'; public function roles() { return \$this->belongsToMany(Role::class); } }\nDB::connection('mysql')->transaction(function () {\n\$user = new User;\n".implode("\n", $statements)."\n});\n";
$joined = implode("\n", array_map(
static fn ($finding): string => $finding->snippet,
array_values(array_filter(
CatalogScanner::scan($source, new AnalysisConfig(defaultDatabaseConnection: 'mysql')),
static fn ($finding): bool => $finding->rule === 'TG021',
)),
));

foreach (OperationCatalog::RELATION_MUTATIONS as $method) {
expect($joined, $method)->toContain("->{$method}(");
}
});
Loading