From 63acabf18633b6dbd1d4b08342e5cfb9d1613c4b Mon Sep 17 00:00:00 2001 From: Gerard Date: Thu, 28 May 2026 12:52:54 +0200 Subject: [PATCH] feat: enforce ADR-0029 closure-scope discipline (queue #86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds EnforceAuditTransactionScopeRule enforcing the success-side of ADR-0029 (Audit Row Durability Contract): non-transactional state mutations (StatefulGuard / Session / Cache / Bus / Queue / Mail / Notification / Broadcast / Storage facades + contracts, mutation methods only) MUST NOT happen inside transaction(...) closures in App\Actions\* classes. Identifier: enforceAuditTransactionScope.nonTransactionalMutationInClosure. Doctrine: ADR-0029 §Decision rule 3. Seed: ISMS-0003 PR #7 commit f1d357b (2026-05-28). Failure-side (sentinel-return; throws inside closure) is enforcement queue #85 — separate Pest arch tests, not bundled. Algorithm: namespace-gate App\Actions\*; locate execute()'s top-level transaction() calls; for each, walk the closure body recursively including nested closures and nested transactions; flag every MethodCall|StaticCall whose receiver-type + method match the blocklist. Instance-call detection matches the constructor-property's declared FQCN against blocklist keys; static-facade detection uses Scope::resolveName(). Reads (Auth::user(), Session::get(), Cache::get()) are deliberately permitted. CHANGELOG [Unreleased] grows; no version bump in this PR. --- CHANGELOG.md | 4 + CLAUDE.md | 1 + README.md | 1 + extension.neon | 3 + .../EnforceAuditTransactionScopeRule.php | 546 ++++++++++++++++++ .../ArrowFunctionClosureSupported.php | 25 + .../CompliantAuditOnlyAction.php | 32 + .../CompliantPostCommitMutation.php | 36 ++ .../CompliantSentinelReturn.php | 46 ++ .../EmptyExecuteMethodSkipped.php | 13 + .../NestedTransactionMutationDetected.php | 29 + .../NonActionNamespaceSkipped.php | 30 + .../NonClosureTransactionArgumentSkipped.php | 26 + .../ReadMethodsAllowedInsideClosure.php | 38 ++ .../ViolationBusDispatchInsideClosure.php | 29 + .../ViolationCacheFacadeInsideClosure.php | 29 + .../ViolationGuardLoginInsideClosure.php | 27 + .../ViolationMailSendInsideClosure.php | 30 + ...ViolationNotificationSendInsideClosure.php | 30 + .../ViolationSessionPutInsideClosure.php | 27 + .../ViolationStorageDeleteInsideClosure.php | 28 + .../EnforceAuditTransactionScopeRuleTest.php | 244 ++++++++ 22 files changed, 1274 insertions(+) create mode 100644 src/Rules/EnforceAuditTransactionScopeRule.php create mode 100644 tests/Fixtures/AuditTransactionScope/ArrowFunctionClosureSupported.php create mode 100644 tests/Fixtures/AuditTransactionScope/CompliantAuditOnlyAction.php create mode 100644 tests/Fixtures/AuditTransactionScope/CompliantPostCommitMutation.php create mode 100644 tests/Fixtures/AuditTransactionScope/CompliantSentinelReturn.php create mode 100644 tests/Fixtures/AuditTransactionScope/EmptyExecuteMethodSkipped.php create mode 100644 tests/Fixtures/AuditTransactionScope/NestedTransactionMutationDetected.php create mode 100644 tests/Fixtures/AuditTransactionScope/NonActionNamespaceSkipped.php create mode 100644 tests/Fixtures/AuditTransactionScope/NonClosureTransactionArgumentSkipped.php create mode 100644 tests/Fixtures/AuditTransactionScope/ReadMethodsAllowedInsideClosure.php create mode 100644 tests/Fixtures/AuditTransactionScope/ViolationBusDispatchInsideClosure.php create mode 100644 tests/Fixtures/AuditTransactionScope/ViolationCacheFacadeInsideClosure.php create mode 100644 tests/Fixtures/AuditTransactionScope/ViolationGuardLoginInsideClosure.php create mode 100644 tests/Fixtures/AuditTransactionScope/ViolationMailSendInsideClosure.php create mode 100644 tests/Fixtures/AuditTransactionScope/ViolationNotificationSendInsideClosure.php create mode 100644 tests/Fixtures/AuditTransactionScope/ViolationSessionPutInsideClosure.php create mode 100644 tests/Fixtures/AuditTransactionScope/ViolationStorageDeleteInsideClosure.php create mode 100644 tests/Rules/EnforceAuditTransactionScopeRuleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d62d5..73bda80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- `EnforceAuditTransactionScopeRule` — enforces ADR-0029 (Audit Row Durability Contract) §Decision rule 3. Flags non-transactional state mutations (`StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` and their `Illuminate\Support\Facades\*` counterparts, mutation methods only) inside `transaction(...)` closures in `App\Actions\*` classes. Identifier: `enforceAuditTransactionScope.nonTransactionalMutationInClosure`. Doctrine: ADR-0029 §Decision rule 3. Seed: ISMS-0003 PR #7 commit `f1d357b` (2026-05-28) — three Auth Actions (`AuthenticateWorkerAction`, `VerifyTwoFactorChallengeAction`, `LogoutWorkerAction`) mutated `StatefulGuard` + `Session` state inside the transaction closure before the audit row write; an audit-write failure would have rolled back the audit row while leaving the session/guard mutation intact (A.8.15 / A.5.33 violation). Reads (`Auth::user()`, `Session::get()`, `Cache::get()`, etc.) are deliberately permitted — only mutations carry the rollback-vs-side-effect asymmetry. Instance-call detection matches the constructor-property's declared FQCN against the blocklist keys; static-call detection resolves the facade name via `Scope::resolveName()`. Nested `transaction(...)` calls inside an outer closure are walked transitively — a nested mutation is still inside the outermost transaction's rollback scope; top-level transaction discovery deduplicates so each violation reports exactly once. Out of scope: manual transaction management (`DB::beginTransaction()` / `commit()`); non-`App\Actions\*` namespaces; the failure-side discipline (sentinel-return; throw-inside-closure detection) which lives as per-territory Pest arch tests under enforcement queue #85. **Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has an `App\Actions\*` class mutating non-transactional state inside a `transaction(...)` closure). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory before tagging — ISMS-0003 PR #7 commit `f1d357b` already closed ISMS's known violators; other consumer territories may carry undetected violators.** + ### Changed - **CI:** pinned `symfony/console` to `^7.2` in `require-dev`. `symfony/console` 8.x (v8.1.0, released 2026-05-29) breaks Infection 0.33.x's mutation runner — its DI container references `Symfony\Component\Console\Helper\QuestionHelper` as a service Symfony Console 8 no longer registers that way, so `composer mutation:ci` aborts with `Unknown service` and exits 1. Because the package's `composer.lock` is gitignored, CI resolves dependencies fresh on every run; `illuminate/*` v13 permits Symfony 8, so the resolver began pulling v8.1.0 and the mutation gate went red fleet-wide (PRs green on 2026-05-28 turned red on 2026-05-29 with no source change). The pin holds the dev toolchain at `symfony/console` v7.4.x — verified mutation gate green (Covered Code MSI 81% ≥ 75) — until Infection ships Symfony Console 8 support, at which point this constraint should be widened or removed. **Versioning:** none (dev-only test-infra; no consumer-facing surface). diff --git a/CLAUDE.md b/CLAUDE.md index 1536404..1226086 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ Composer package distributing war-room-doctrine PHPStan rules across `script-dev | `LogRule` | ADR-0001 §Append-only | `logRule.logModification` (covers instance `update`/`delete`/`forceDelete`/`forceDeleteQuietly`; static `Model::destroy()` / `Model::forceDestroy()` ship with v0.3.0 per `[Unreleased]`) | | `LogBuilderTruncateRule` | ADR-0001 §Append-only | `logRule.logModification` (shared with `LogRule`; covers `Builder->truncate()` on Log-named tables — ships with v0.3.0 per `[Unreleased]`) | | `EnforceAuditSnapshotOnRetryRule` | ADR-0001 §Snapshot-on-Retry Safety | `enforceAuditSnapshotOnRetry.firstStatementMustResetState` | +| `EnforceAuditTransactionScopeRule` | ADR-0029 | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` | | `EnforceResourceDataValidatorOptInRule` | ADR-0009 §EAGER_LOAD validator opt-in | `enforceResourceDataValidatorOptIn.missingValidatorCall` | | `ConnectionTransactionReturnTypeExtension` | (type extension, no rule) | — | diff --git a/README.md b/README.md index f0af55e..fe30957 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ includes: | `LogRule` | `logRule.logModification` | `update()` / `delete()` calls | If the receiver type's class name contains `"Log"` or `"logs"` (case-insensitive), error. | | `LogBuilderTruncateRule` | `logRule.logModification` | `Builder->truncate()` calls | If the fluent chain's most recent `table()` call targets a Log-named table (string-literal argument matching `"log"` / `"logs"`, case-insensitive), error. Sibling rule to `LogRule`; shares the `logRule.logModification` identifier so a single `ignoreErrors` entry covers both. Eloquent `from()` chains and Model-`$table`-property-driven tables are acceptable misses. Doctrine: ADR-0001 §Append-only. | | `EnforceAuditSnapshotOnRetryRule` | `enforceAuditSnapshotOnRetry.firstStatementMustResetState` | `App\Actions\*` whose constructor injects an entity audit logger | The first statement inside `$connection->transaction(...)` must reset the model's in-memory state (`$model->refresh()`, fresh fetch, or fresh instantiation). Doctrine: ADR-0001 §Snapshot-on-Retry Safety. | +| `EnforceAuditTransactionScopeRule` | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` | `App\Actions\*` whose `execute()` calls `transaction(...)` with a literal closure | Mutating `StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` state (or their `Illuminate\Support\Facades\*` counterparts) inside the closure is an error. Reads (`Auth::user()`, `Session::get()`, `Cache::get()`) are permitted. Doctrine: ADR-0029 (Audit Row Durability Contract) §Decision rule 3. | | `EnforceResourceDataValidatorOptInRule` | `enforceResourceDataValidatorOptIn.missingValidatorCall` | Classes extending `App\Http\Resources\ResourceData` | If the class declares a non-empty `EAGER_LOAD_COUNT` / `EAGER_LOAD_SUM` constant but never calls `validateRelationsLoaded()` in any method, error. | ### `EnforceActionTransactionsRule` — write-method list diff --git a/extension.neon b/extension.neon index dea67f2..97921e2 100644 --- a/extension.neon +++ b/extension.neon @@ -27,6 +27,9 @@ services: - class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceAuditSnapshotOnRetryRule tags: [phpstan.rules.rule] + - + class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceAuditTransactionScopeRule + tags: [phpstan.rules.rule] - class: ScriptDevelopment\PhpstanWarroomRules\Rules\EnforceResourceDataValidatorOptInRule arguments: diff --git a/src/Rules/EnforceAuditTransactionScopeRule.php b/src/Rules/EnforceAuditTransactionScopeRule.php new file mode 100644 index 0000000..4302ff2 --- /dev/null +++ b/src/Rules/EnforceAuditTransactionScopeRule.php @@ -0,0 +1,546 @@ +guard->login(...)` form: walks the + * `PropertyFetch` to a constructor parameter and matches the declared type + * (or its short FQCN) against the blocklist keys. Same shape as + * `EnforceActionTransactionsRule::getNonDatabasePropertyNames()`. + * + * Static-facade detection — `Auth::login(...)` form: matches the `StaticCall` + * class via `$scope->resolveName()` against the blocklist's facade FQCNs. + * + * Out of scope: + * + * - Manual transaction management (`DB::beginTransaction()` / `commit()`). + * Only `transaction(Closure)` with a literal closure first-arg fires. + * - Non-`App\Actions\*` namespaces — Services, Jobs, Commands, Middleware + * have their own disciplines (or lack thereof) that ADR-0029 doesn't + * cover. + * - Reads of facades/contracts inside the closure — explicitly permitted. + * + * @implements Rule + */ +final class EnforceAuditTransactionScopeRule implements Rule +{ + /** + * Blocklist of mutation methods that MUST NOT execute inside a + * `transaction(...)` closure when the receiver is the named type. + * + * Keys are fully-qualified class names (facades + contracts). Values are + * the mutation methods. Reads are deliberately omitted — `Auth::user()`, + * `Session::get()`, `Cache::get()`, etc. carry no rollback risk. + * + * @var array> + */ + private const array BLOCKLIST = [ + // StatefulGuard — worker auth, web guard. Mutation methods only. + StatefulGuard::class => [ + 'login', 'logout', 'attempt', 'loginUsingId', 'logoutCurrentDevice', + 'viaRemember', 'once', 'onceUsingId', 'attemptWhen', 'logoutOtherDevices', + ], + // Auth facade — static-call form of StatefulGuard mutations. + Auth::class => [ + 'login', 'logout', 'attempt', 'loginUsingId', 'logoutCurrentDevice', + 'viaRemember', 'once', 'onceUsingId', 'attemptWhen', 'logoutOtherDevices', + ], + // Session contract — non-transactional session-store mutations. + Session::class => [ + 'regenerate', 'invalidate', 'put', 'forget', 'remove', 'flush', 'migrate', + 'regenerateToken', 'flash', 'reflash', 'keep', 'now', 'increment', 'decrement', + 'push', 'replace', 'pull', + ], + \Illuminate\Support\Facades\Session::class => [ + 'regenerate', 'invalidate', 'put', 'forget', 'remove', 'flush', 'migrate', + 'regenerateToken', 'flash', 'reflash', 'keep', 'now', 'increment', 'decrement', + 'push', 'replace', 'pull', + ], + // Cache repository — mutation methods only. + Repository::class => [ + 'put', 'forget', 'flush', 'delete', 'add', 'pull', 'increment', 'decrement', + 'forever', 'remember', 'rememberForever', 'sear', 'restoreLock', + ], + Cache::class => [ + 'put', 'forget', 'flush', 'delete', 'add', 'pull', 'increment', 'decrement', + 'forever', 'remember', 'rememberForever', 'sear', + ], + // Bus / Queue dispatching. + Dispatcher::class => [ + 'dispatch', 'dispatchNow', 'dispatchSync', 'dispatchAfterResponse', 'dispatchToQueue', + ], + Bus::class => [ + 'dispatch', 'dispatchNow', 'dispatchSync', 'dispatchAfterResponse', + ], + Queue::class => [ + 'push', 'pushOn', 'later', 'laterOn', 'bulk', + ], + \Illuminate\Support\Facades\Queue::class => [ + 'push', 'pushOn', 'later', 'laterOn', 'bulk', + ], + // Mail. + Mailer::class => [ + 'send', 'sendNow', 'raw', 'plain', 'html', 'queue', 'later', + ], + Mail::class => [ + 'send', 'raw', 'plain', 'html', 'queue', 'later', 'to', 'cc', 'bcc', + ], + // Notifications. + \Illuminate\Contracts\Notifications\Dispatcher::class => [ + 'send', 'sendNow', + ], + Notification::class => [ + 'send', 'sendNow', 'route', + ], + // Broadcasting. + Broadcaster::class => [ + 'broadcast', + ], + Broadcast::class => [ + 'event', 'channel', + ], + // Filesystem mutations. + Filesystem::class => [ + 'put', 'putFile', 'putFileAs', 'delete', 'move', 'copy', 'prepend', 'append', + 'setVisibility', 'makeDirectory', 'deleteDirectory', + ], + Storage::class => [ + 'put', 'putFile', 'putFileAs', 'delete', 'move', 'copy', 'prepend', 'append', + 'setVisibility', 'makeDirectory', 'deleteDirectory', + ], + ]; + + public function getNodeType(): string + { + return Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $namespace = $scope->getNamespace(); + + if ($namespace === null || !str_starts_with($namespace, 'App\Actions')) { + return []; + } + + $executeMethod = $node->getMethod('execute'); + + if ($executeMethod === null || $executeMethod->stmts === null) { + return []; + } + + $classFqcn = $this->resolveClassFqcn($node, $namespace); + $constructorPropertyTypes = $this->getConstructorPropertyTypes($node); + $transactionCalls = $this->findTransactionCalls($executeMethod); + + $errors = []; + + foreach ($transactionCalls as $transactionCall) { + $closure = $this->extractClosureArgument($transactionCall); + + if ($closure === null) { + continue; + } + + foreach ($this->findBlocklistViolations($closure, $constructorPropertyTypes, $scope) as $violation) { + $errors[] = $this->buildError($classFqcn, $violation); + } + } + + return $errors; + } + + /** + * @param array{type: string, method: string, node: Node} $violation + */ + private function buildError(string $classFqcn, array $violation): IdentifierRuleError + { + $typeShortName = $this->shortName($violation['type']); + + $message = sprintf( + 'Action %s mutates non-transactional state (%s::%s) inside a database transaction closure. ' + . 'ADR-0029 (Audit Row Durability Contract) requires non-transactional state mutation to happen ' + . 'post-commit outside the closure, so an audit-write failure cannot leave observable side effects ' + . 'without an audit row.', + $classFqcn, + $typeShortName, + $violation['method'], + ); + + return RuleErrorBuilder::message($message) + ->identifier('enforceAuditTransactionScope.nonTransactionalMutationInClosure') + ->line($violation['node']->getStartLine()) + ->build(); + } + + /** + * Walk the method body and return every top-level `transaction()` call + * node — i.e. transaction calls that are NOT themselves nested inside + * another transaction closure. Nested transactions are processed by + * `findBlocklistViolations()` recursing into the outer closure's body; + * counting them here too would double-report every nested-call + * violation. + * + * Covers `MethodCall` (`$this->db->transaction(...)`) and `StaticCall` + * (`DB::transaction(...)`) shapes. + * + * @return list + */ + private function findTransactionCalls(ClassMethod $method): array + { + $calls = []; + + foreach ($method->stmts ?? [] as $stmt) { + $this->collectTopLevelTransactions($stmt, $calls); + } + + return $calls; + } + + /** + * @param list $calls + */ + private function collectTopLevelTransactions(Node $node, array &$calls): void + { + if ($this->isTransactionCall($node)) { + /** @var MethodCall|StaticCall $node */ + $calls[] = $node; + + // Don't recurse into a transaction call's children — its closure + // body is processed by findBlocklistViolations, and nested + // transactions inside the closure are walked there. + return; + } + + foreach ($node->getSubNodeNames() as $name) { + $subNode = $node->{$name}; + + if ($subNode instanceof Node) { + $this->collectTopLevelTransactions($subNode, $calls); + } elseif (is_array($subNode)) { + foreach ($subNode as $item) { + if ($item instanceof Node) { + $this->collectTopLevelTransactions($item, $calls); + } + } + } + } + } + + private function isTransactionCall(Node $node): bool + { + if ( + $node instanceof MethodCall + && $node->name instanceof Identifier + && $node->name->toString() === 'transaction' + ) { + return true; + } + + return $node instanceof StaticCall + && $node->name instanceof Identifier + && $node->name->toString() === 'transaction'; + } + + /** + * Extract the first-argument closure (or arrow function) from a + * `transaction()` call. Returns null if the first argument is not a + * literal closure — uncommon shapes (passing a Closure variable) are + * out of scope. + */ + private function extractClosureArgument(MethodCall|StaticCall $call): ArrowFunction|Closure|null + { + if (!isset($call->args[0]) || !$call->args[0] instanceof Arg) { + return null; + } + + $value = $call->args[0]->value; + + if ($value instanceof Closure || $value instanceof ArrowFunction) { + return $value; + } + + return null; + } + + /** + * Walk the closure body recursively looking for blocklist violations. + * Nested closures + nested `transaction(...)` calls are walked into — + * a nested mutation still sits inside the outer transaction's rollback + * scope. + * + * @param array $constructorPropertyTypes property-name => FQCN + * + * @return list + */ + private function findBlocklistViolations( + ArrowFunction|Closure $closure, + array $constructorPropertyTypes, + Scope $scope, + ): array { + $violations = []; + + $body = $closure instanceof Closure ? $closure->stmts : [$closure->expr]; + + $this->walkNodes($body, function(Node $node) use (&$violations, $constructorPropertyTypes, $scope): void { + if ($node instanceof MethodCall) { + $violation = $this->checkInstanceCall($node, $constructorPropertyTypes); + + if ($violation !== null) { + $violations[] = $violation; + } + + return; + } + + if ($node instanceof StaticCall) { + $violation = $this->checkStaticCall($node, $scope); + + if ($violation !== null) { + $violations[] = $violation; + } + } + }); + + return $violations; + } + + /** + * Match `$this->property->method(...)` against the blocklist by resolving + * the property's declared constructor type. + * + * @param array $constructorPropertyTypes property-name => FQCN + * + * @return array{type: string, method: string, node: MethodCall}|null + */ + private function checkInstanceCall(MethodCall $node, array $constructorPropertyTypes): ?array + { + if (!$node->name instanceof Identifier) { + return null; + } + + $methodName = $node->name->toString(); + + if ( + !$node->var instanceof PropertyFetch + || !$node->var->var instanceof Variable + || $node->var->var->name !== 'this' + || !$node->var->name instanceof Identifier + ) { + return null; + } + + $propertyName = $node->var->name->toString(); + $propertyType = $constructorPropertyTypes[$propertyName] ?? null; + + if ($propertyType === null) { + return null; + } + + if (!isset(self::BLOCKLIST[$propertyType])) { + return null; + } + + if (!in_array($methodName, self::BLOCKLIST[$propertyType], true)) { + return null; + } + + return ['type' => $propertyType, 'method' => $methodName, 'node' => $node]; + } + + /** + * Match `Auth::method(...)` against the blocklist by resolving the + * static-call's class name through the file's use-import map. + * + * @return array{type: string, method: string, node: StaticCall}|null + */ + private function checkStaticCall(StaticCall $node, Scope $scope): ?array + { + if (!$node->name instanceof Identifier) { + return null; + } + + $methodName = $node->name->toString(); + + if (!$node->class instanceof Name) { + return null; + } + + $resolvedClass = $scope->resolveName($node->class); + + if (!isset(self::BLOCKLIST[$resolvedClass])) { + return null; + } + + if (!in_array($methodName, self::BLOCKLIST[$resolvedClass], true)) { + return null; + } + + return ['type' => $resolvedClass, 'method' => $methodName, 'node' => $node]; + } + + /** + * Build a property-name => FQCN map from the constructor's promoted + * parameters. Mirrors `EnforceActionTransactionsRule::getNonDatabase + * PropertyNames()` but returns the FQCN as the value so callers can + * match against blocklist keys directly. + * + * @return array + */ + private function getConstructorPropertyTypes(Class_ $node): array + { + $constructor = $node->getMethod('__construct'); + + if (!$constructor instanceof ClassMethod) { + return []; + } + + $map = []; + + foreach ($constructor->getParams() as $param) { + if (!$param->type instanceof Name) { + continue; + } + + if (!$param->var instanceof Variable) { + continue; + } + + if (!is_string($param->var->name)) { + continue; + } + + $map[$param->var->name] = $param->type->toString(); + } + + return $map; + } + + /** + * Resolve the fully-qualified class name from the AST node + namespace. + * Avoids depending on `$scope->getClassReflection()`, which can return + * null during fixture-mode analysis where the class isn't autoloadable. + */ + private function resolveClassFqcn(Class_ $node, string $namespace): string + { + if ($node->name === null) { + return $namespace; + } + + return $namespace . '\\' . $node->name->toString(); + } + + private function shortName(string $fqcn): string + { + $pos = mb_strrpos($fqcn, '\\'); + + if ($pos === false) { + return $fqcn; + } + + return mb_substr($fqcn, $pos + 1); + } + + /** + * Recursively walk a list of nodes, invoking `$callback` on each one. + * Mirrors `EnforceActionTransactionsRule::walkNodes()` for parity — + * re-evaluate at v1.0 once the duplication trigger is acted on. + * + * @param array $nodes + */ + private function walkNodes(array $nodes, callable $callback): void + { + foreach ($nodes as $node) { + if (!$node instanceof Node) { + continue; + } + + $callback($node); + + foreach ($node->getSubNodeNames() as $name) { + $subNode = $node->{$name}; + + if ($subNode instanceof Node) { + $this->walkNodes([$subNode], $callback); + } elseif (is_array($subNode)) { + $this->walkNodes( + array_filter($subNode, static fn(mixed $item): bool => $item instanceof Node), + $callback, + ); + } + } + } + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ArrowFunctionClosureSupported.php b/tests/Fixtures/AuditTransactionScope/ArrowFunctionClosureSupported.php new file mode 100644 index 0000000..539182c --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ArrowFunctionClosureSupported.php @@ -0,0 +1,25 @@ +db->transaction(fn(): mixed => $this->session->put('k', 'v')); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/CompliantAuditOnlyAction.php b/tests/Fixtures/AuditTransactionScope/CompliantAuditOnlyAction.php new file mode 100644 index 0000000..a92107c --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/CompliantAuditOnlyAction.php @@ -0,0 +1,32 @@ +db->transaction(function() use ($user): void { + $this->logger->logLoggedOut($user); + }, 3); + + // Post-commit: audit row is durable. Tear down non-transactional state. + $this->guard->logout(); + $this->session->invalidate(); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/CompliantPostCommitMutation.php b/tests/Fixtures/AuditTransactionScope/CompliantPostCommitMutation.php new file mode 100644 index 0000000..1e079d8 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/CompliantPostCommitMutation.php @@ -0,0 +1,36 @@ +db->transaction(function() use ($user): User { + $user->refresh(); + $user->last_login_at = now(); + $user->save(); + $this->logger->logUpdated($user); + + return $user; + }, 3); + + // Post-commit: audit row is durable. Mutate non-transactional state. + $this->session->put('last_user_id', $result->id); + + return $result; + } +} diff --git a/tests/Fixtures/AuditTransactionScope/CompliantSentinelReturn.php b/tests/Fixtures/AuditTransactionScope/CompliantSentinelReturn.php new file mode 100644 index 0000000..a33e35f --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/CompliantSentinelReturn.php @@ -0,0 +1,46 @@ +db->transaction(function() use ($email): User|InvalidCredentialsException { + $user = User::query()->where('email', $email)->first(); + + if ($user === null) { + $this->logger->logLoginFailed(null, $email); + + return new InvalidCredentialsException; + } + + $this->logger->logLoggedIn($user); + + return $user; + }, 3); + + if ($result instanceof InvalidCredentialsException) { + throw $result; + } + + // Post-commit: audit row is durable. Mutate non-transactional state. + $this->guard->login($result); + + return $result; + } +} diff --git a/tests/Fixtures/AuditTransactionScope/EmptyExecuteMethodSkipped.php b/tests/Fixtures/AuditTransactionScope/EmptyExecuteMethodSkipped.php new file mode 100644 index 0000000..26901d0 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/EmptyExecuteMethodSkipped.php @@ -0,0 +1,13 @@ +db->transaction(function() use ($widget): void { + $widget->refresh(); + $this->db->transaction(function() use ($widget): void { + Cache::put('widget.' . $widget->id, $widget); + }, 3); + $this->logger->logUpdated($widget); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/NonActionNamespaceSkipped.php b/tests/Fixtures/AuditTransactionScope/NonActionNamespaceSkipped.php new file mode 100644 index 0000000..822d85f --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/NonActionNamespaceSkipped.php @@ -0,0 +1,30 @@ +db->transaction(function() use ($widget): void { + $this->guard->logout(); + $widget->save(); + $this->logger->logUpdated($widget); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/NonClosureTransactionArgumentSkipped.php b/tests/Fixtures/AuditTransactionScope/NonClosureTransactionArgumentSkipped.php new file mode 100644 index 0000000..b8c5a17 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/NonClosureTransactionArgumentSkipped.php @@ -0,0 +1,26 @@ +db->transaction($work, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ReadMethodsAllowedInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ReadMethodsAllowedInsideClosure.php new file mode 100644 index 0000000..4a25bf0 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ReadMethodsAllowedInsideClosure.php @@ -0,0 +1,38 @@ +db->transaction(function() use ($widget): void { + // Reads are deliberately permitted — no rollback-vs-side-effect asymmetry. + $actor = Auth::user(); + $context = $this->session->get('context'); + $cachedFlag = $this->cache->get('flag.' . $widget->id); + $derived = Cache::get('derived.' . $widget->id); + + $widget->refresh(); + $widget->save(); + $this->logger->logUpdated($widget); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ViolationBusDispatchInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ViolationBusDispatchInsideClosure.php new file mode 100644 index 0000000..bc0b35f --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ViolationBusDispatchInsideClosure.php @@ -0,0 +1,29 @@ +db->transaction(function() use ($widget): void { + $widget->refresh(); + $widget->save(); + Bus::dispatch(new NotifyWidgetUpdated($widget)); + $this->logger->logUpdated($widget); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ViolationCacheFacadeInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ViolationCacheFacadeInsideClosure.php new file mode 100644 index 0000000..ff3d89f --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ViolationCacheFacadeInsideClosure.php @@ -0,0 +1,29 @@ +db->transaction(function() use ($widget): void { + $widget->refresh(); + $widget->name = 'updated'; + $widget->save(); + Cache::put('widget.' . $widget->id, $widget); + $this->logger->logUpdated($widget); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ViolationGuardLoginInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ViolationGuardLoginInsideClosure.php new file mode 100644 index 0000000..9b5456b --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ViolationGuardLoginInsideClosure.php @@ -0,0 +1,27 @@ +db->transaction(function() use ($user): void { + $this->guard->login($user); + $this->logger->logLoggedIn($user); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ViolationMailSendInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ViolationMailSendInsideClosure.php new file mode 100644 index 0000000..7e717f0 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ViolationMailSendInsideClosure.php @@ -0,0 +1,30 @@ +db->transaction(function() use ($widget): void { + $widget->refresh(); + $widget->save(); + $this->mailer->send(new WidgetUpdated($widget)); + $this->logger->logUpdated($widget); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ViolationNotificationSendInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ViolationNotificationSendInsideClosure.php new file mode 100644 index 0000000..abacca0 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ViolationNotificationSendInsideClosure.php @@ -0,0 +1,30 @@ +db->transaction(function() use ($widget, $user): void { + $widget->refresh(); + $widget->save(); + Notification::send($user, new WidgetUpdatedNotification($widget)); + $this->logger->logUpdated($widget); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ViolationSessionPutInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ViolationSessionPutInsideClosure.php new file mode 100644 index 0000000..d827ed3 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ViolationSessionPutInsideClosure.php @@ -0,0 +1,27 @@ +db->transaction(function() use ($user): void { + $this->session->put('user_id', $user->id); + $this->logger->logLoggedIn($user); + }, 3); + } +} diff --git a/tests/Fixtures/AuditTransactionScope/ViolationStorageDeleteInsideClosure.php b/tests/Fixtures/AuditTransactionScope/ViolationStorageDeleteInsideClosure.php new file mode 100644 index 0000000..35e30c9 --- /dev/null +++ b/tests/Fixtures/AuditTransactionScope/ViolationStorageDeleteInsideClosure.php @@ -0,0 +1,28 @@ +db->transaction(function() use ($widget): void { + $widget->refresh(); + $widget->delete(); + Storage::delete('widgets/' . $widget->id . '.json'); + $this->logger->logDeleted($widget); + }, 3); + } +} diff --git a/tests/Rules/EnforceAuditTransactionScopeRuleTest.php b/tests/Rules/EnforceAuditTransactionScopeRuleTest.php new file mode 100644 index 0000000..635a4b9 --- /dev/null +++ b/tests/Rules/EnforceAuditTransactionScopeRuleTest.php @@ -0,0 +1,244 @@ + + */ +final class EnforceAuditTransactionScopeRuleTest extends RuleTestCase +{ + public function testCompliantPostCommitMutation(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/CompliantPostCommitMutation.php'], + [], + ); + } + + public function testCompliantSentinelReturn(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/CompliantSentinelReturn.php'], + [], + ); + } + + public function testCompliantAuditOnlyAction(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/CompliantAuditOnlyAction.php'], + [], + ); + } + + public function testViolationGuardLoginInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ViolationGuardLoginInsideClosure.php'], + [ + [ + $this->message( + 'App\Actions\Auth\ViolationGuardLoginInsideClosure', + 'StatefulGuard', + 'login', + ), + 23, + ], + ], + ); + } + + public function testViolationSessionPutInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ViolationSessionPutInsideClosure.php'], + [ + [ + $this->message( + 'App\Actions\Auth\ViolationSessionPutInsideClosure', + 'Session', + 'put', + ), + 23, + ], + ], + ); + } + + public function testViolationCacheFacadeInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ViolationCacheFacadeInsideClosure.php'], + [ + [ + $this->message( + 'App\Actions\Widget\ViolationCacheFacadeInsideClosure', + 'Cache', + 'put', + ), + 25, + ], + ], + ); + } + + public function testViolationBusDispatchInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ViolationBusDispatchInsideClosure.php'], + [ + [ + $this->message( + 'App\Actions\Widget\ViolationBusDispatchInsideClosure', + 'Bus', + 'dispatch', + ), + 25, + ], + ], + ); + } + + public function testViolationMailSendInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ViolationMailSendInsideClosure.php'], + [ + [ + $this->message( + 'App\Actions\Widget\ViolationMailSendInsideClosure', + 'Mailer', + 'send', + ), + 26, + ], + ], + ); + } + + public function testViolationNotificationSendInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ViolationNotificationSendInsideClosure.php'], + [ + [ + $this->message( + 'App\Actions\Widget\ViolationNotificationSendInsideClosure', + 'Notification', + 'send', + ), + 26, + ], + ], + ); + } + + public function testViolationStorageDeleteInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ViolationStorageDeleteInsideClosure.php'], + [ + [ + $this->message( + 'App\Actions\Widget\ViolationStorageDeleteInsideClosure', + 'Storage', + 'delete', + ), + 24, + ], + ], + ); + } + + public function testReadMethodsAllowedInsideClosure(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ReadMethodsAllowedInsideClosure.php'], + [], + ); + } + + public function testNonActionNamespaceSkipped(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/NonActionNamespaceSkipped.php'], + [], + ); + } + + public function testEmptyExecuteMethodSkipped(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/EmptyExecuteMethodSkipped.php'], + [], + ); + } + + public function testNonClosureTransactionArgumentSkipped(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/NonClosureTransactionArgumentSkipped.php'], + [], + ); + } + + public function testNestedTransactionMutationDetected(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/NestedTransactionMutationDetected.php'], + [ + [ + $this->message( + 'App\Actions\Widget\NestedTransactionMutationDetected', + 'Cache', + 'put', + ), + 24, + ], + ], + ); + } + + public function testArrowFunctionClosureSupported(): void + { + $this->analyse( + [__DIR__ . '/../Fixtures/AuditTransactionScope/ArrowFunctionClosureSupported.php'], + [ + [ + $this->message( + 'App\Actions\Widget\ArrowFunctionClosureSupported', + 'Session', + 'put', + ), + 23, + ], + ], + ); + } + + protected function getRule(): Rule + { + return new EnforceAuditTransactionScopeRule; + } + + private function message(string $classFqcn, string $typeShortName, string $method): string + { + return sprintf( + 'Action %s mutates non-transactional state (%s::%s) inside a database transaction closure. ' + . 'ADR-0029 (Audit Row Durability Contract) requires non-transactional state mutation to happen ' + . 'post-commit outside the closure, so an audit-write failure cannot leave observable side effects ' + . 'without an audit row.', + $classFqcn, + $typeShortName, + $method, + ); + } +}