Repository navigation
feat: invalidate caches when Architecture changes #521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
725bb3d
feat: fingerprint evaluated architecture configuration
NickSdot 66c50f9
review: replace WeakMap with SplObjectStorage
NickSdot ef6b270
review: construct fingerprint in metadata factory constructor
NickSdot 4705d92
review: improve test coverage
NickSdot 8dcac60
review: added e2e test
NickSdot 0c819aa
review: fix readonly settings and nested rule fingerprints
NickSdot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,263 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Boundwize\StructArmed\Cache; | ||
|
|
||
| use Boundwize\StructArmed\Architecture; | ||
| use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; | ||
| use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; | ||
| use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; | ||
| use Boundwize\StructArmed\Rule\FunctionRuleInterface; | ||
| use Boundwize\StructArmed\Rule\ProjectRuleInterface; | ||
| use Boundwize\StructArmed\Rule\RuleInterface; | ||
| use Boundwize\StructArmed\Rule\Rules\Composer\Psr4NamespaceRule; | ||
| use Closure; | ||
| use DateTimeImmutable; | ||
| use ReflectionClass; | ||
| use ReflectionFunction; | ||
| use SplObjectStorage; | ||
| use UnitEnum; | ||
|
|
||
| use function get_resource_type; | ||
| use function gettype; | ||
| use function hash; | ||
| use function in_array; | ||
| use function is_array; | ||
| use function is_callable; | ||
| use function is_object; | ||
| use function is_resource; | ||
| use function ksort; | ||
| use function serialize; | ||
|
|
||
| /** | ||
| * Captures evaluated configuration without invoking user code or serializing service graphs. | ||
| * | ||
| * @internal | ||
| */ | ||
| final class ConfigurationFingerprint | ||
| { | ||
| private const MAX_DEPTH = 32; | ||
|
|
||
| /** Native state populated by analysis, rather than configuration loading. */ | ||
| private const RUNTIME_PROPERTIES = [ | ||
| AbstractLayerAwareRule::class => ['classNodeMap'], | ||
| Psr4NamespaceRule::class => ['projectBasePath', 'mappingsByBasePath', 'basePathByDirectory'], | ||
| ]; | ||
|
|
||
| /** @var SplObjectStorage<object, array<string, string>> */ | ||
| private SplObjectStorage $objects; | ||
|
|
||
| private int $boundaries = 0; | ||
|
|
||
| public function __construct() | ||
| { | ||
| $this->objects = new SplObjectStorage(); | ||
| } | ||
|
|
||
| public function hash(Architecture $architecture): string | ||
| { | ||
| $this->objects = new SplObjectStorage(); | ||
| $this->boundaries = 0; | ||
| $rules = []; | ||
|
|
||
| foreach ($architecture->getRules() as $key => $rule) { | ||
| if (! in_array($key, $architecture->getSkippedRuleKeys(), true)) { | ||
| $rules[$key] = $this->properties($rule, [], 0); | ||
| } | ||
| } | ||
|
|
||
| // Registration order affects precedence. Baselines are applied after cached | ||
| // evaluation; the cache directory is a storage location, not an evaluation input. | ||
| $fingerprint = hash('xxh128', serialize([ | ||
| $architecture->getLayers(), | ||
| $architecture->getLayerExcludePaths(), | ||
| $architecture->getLayerPatterns(), | ||
| $architecture->getRuleset(), | ||
| $architecture->getClassViolationSkips(), | ||
| $architecture->getRulesetSkipPaths(), | ||
| $architecture->getSkipPaths(), | ||
| $architecture->getRuleSkipPaths(), | ||
| $architecture->getSkippedRuleKeys(), | ||
| $rules, | ||
| ])); | ||
|
|
||
| // SplObjectStorage holds strong references; release the graph after this run. | ||
| $this->objects = new SplObjectStorage(); | ||
|
|
||
| return $fingerprint; | ||
| } | ||
|
|
||
| /** | ||
| * Rules contribute configured state, excluding known injected runtime fields. | ||
| * Other objects contribute only readonly properties, leaving mutable service state opaque. | ||
| * | ||
| * @param list<object> $ancestors | ||
| * @return list<mixed> | ||
| */ | ||
| private function properties(object $object, array $ancestors, int $depth, bool $readonlyOnly = false): array | ||
| { | ||
| $class = new ReflectionClass($object); | ||
| $identity = $this->classIdentity($class); | ||
|
|
||
| $isLazy = $this->reflectionMethod($class, 'isUninitializedLazyObject'); | ||
| if ($isLazy instanceof Closure && $isLazy($object)) { | ||
| return ['opaque-lazy', $identity]; | ||
| } | ||
|
|
||
| if (in_array($object, $ancestors, true) || $depth >= self::MAX_DEPTH) { | ||
| $this->boundaries++; | ||
|
|
||
| return ['boundary', $identity]; | ||
| } | ||
|
|
||
| $memoKey = $depth . ':' . ($readonlyOnly ? 'readonly' : 'all'); | ||
|
|
||
| if (isset($this->objects[$object][$memoKey])) { | ||
| return ['object-digest', $this->objects[$object][$memoKey]]; | ||
| } | ||
|
|
||
| $boundaries = $this->boundaries; | ||
| $ancestors[] = $object; | ||
| $properties = []; | ||
|
|
||
| do { | ||
| foreach ($class->getProperties() as $property) { | ||
| if ($property->getDeclaringClass()->getName() !== $class->getName() || $property->isStatic()) { | ||
| continue; | ||
| } | ||
|
|
||
| if ($readonlyOnly && ! $property->isReadOnly()) { | ||
| continue; | ||
| } | ||
|
|
||
| $name = $property->getName(); | ||
|
|
||
| if (in_array($name, self::RUNTIME_PROPERTIES[$class->getName()] ?? [], true)) { | ||
| continue; | ||
| } | ||
|
|
||
| // PHP 8.4 hooks may run arbitrary code. Virtual properties have no | ||
| // stored configuration; backed properties are read without hooks. | ||
| $isVirtual = $this->reflectionMethod($property, 'isVirtual'); | ||
| if ($isVirtual instanceof Closure && $isVirtual()) { | ||
| continue; | ||
| } | ||
|
|
||
| $value = ['uninitialized']; | ||
|
|
||
| if ($property->isInitialized($object)) { | ||
| $getRawValue = $this->reflectionMethod($property, 'getRawValue'); | ||
| $stored = $getRawValue instanceof Closure | ||
| ? $getRawValue($object) | ||
| : $property->getValue($object); | ||
| $value = $this->value($stored, $ancestors, $depth + 1); | ||
| } | ||
|
|
||
| $properties[] = [$this->classIdentity($class), $name, $value]; | ||
| } | ||
|
|
||
| $class = $class->getParentClass(); | ||
| } while ($class !== false); | ||
|
|
||
| $digest = hash('xxh128', serialize(['object', $identity, $properties])); | ||
|
|
||
| // Cycles and depth cutoffs depend on the current traversal path. Never | ||
| // memoize them. Depth and property selection belong to the memo key, not the persisted identity. | ||
| if ($this->boundaries === $boundaries) { | ||
| $memo = $this->objects[$object] ?? []; | ||
| $memo[$memoKey] = $digest; | ||
| $this->objects[$object] = $memo; | ||
| } | ||
|
|
||
| return ['object-digest', $digest]; | ||
| } | ||
|
|
||
| /** | ||
| * @param list<object> $ancestors | ||
| */ | ||
| private function value(mixed $value, array $ancestors, int $depth): mixed | ||
| { | ||
| if ($depth >= self::MAX_DEPTH) { | ||
| $this->boundaries++; | ||
|
|
||
| return ['boundary']; | ||
| } | ||
|
|
||
| if (is_array($value)) { | ||
| $items = []; | ||
|
|
||
| foreach ($value as $key => $item) { | ||
| $items[] = [$key, $this->value($item, $ancestors, $depth + 1)]; | ||
| } | ||
|
|
||
| return ['array', $items]; | ||
| } | ||
|
|
||
| if ($value instanceof UnitEnum) { | ||
| return ['enum', $value::class, $value->name]; | ||
| } | ||
|
|
||
| if ($value instanceof DateTimeImmutable) { | ||
| return ['date', $value::class, DateTimeImmutable::createFromInterface($value)->format('Y-m-d H:i:s.u e')]; | ||
| } | ||
|
|
||
| if ($value instanceof Closure) { | ||
| $function = new ReflectionFunction($value); | ||
| $captures = $function->getStaticVariables(); | ||
| ksort($captures); | ||
| $scope = $function->getClosureScopeClass(); | ||
| $called = $function->getClosureCalledClass(); | ||
|
|
||
| // Reflection exposes no source column or anonymous bytecode identity. | ||
| // Distinct same-line bodies with identical context remain indistinguishable. | ||
| return [ | ||
| 'closure', | ||
| $function->isAnonymous() ? null : $function->getName(), | ||
| $scope instanceof ReflectionClass ? $this->classIdentity($scope) : null, | ||
| $called instanceof ReflectionClass ? $this->classIdentity($called) : null, | ||
| $this->value($function->getClosureThis(), $ancestors, $depth + 1), | ||
| $function->getFileName(), | ||
| $function->getStartLine(), | ||
| $function->getEndLine(), | ||
| $this->value($captures, $ancestors, $depth + 1), | ||
| ]; | ||
| } | ||
|
|
||
| if (is_object($value)) { | ||
| $isRule = $value instanceof RuleInterface | ||
| || $value instanceof ProjectRuleInterface | ||
| || $value instanceof FunctionRuleInterface | ||
| || $value instanceof AnonymousFunctionRuleInterface | ||
| || $value instanceof AnonymousClassRuleInterface; | ||
|
|
||
| return $this->properties($value, $ancestors, $depth, readonlyOnly: ! $isRule); | ||
| } | ||
|
|
||
| if (is_resource($value)) { | ||
| return ['resource', get_resource_type($value)]; | ||
| } | ||
|
|
||
| return gettype($value) === 'resource (closed)' ? ['resource', 'closed'] : ['scalar', $value]; | ||
| } | ||
|
|
||
| /** Resolve optional reflection APIs without requiring PHP 8.4 method declarations. */ | ||
| private function reflectionMethod(object $reflection, string $method): ?Closure | ||
| { | ||
| return is_callable([$reflection, $method]) ? Closure::fromCallable([$reflection, $method]) : null; | ||
| } | ||
|
|
||
| /** | ||
| * Anonymous class names contain a generated counter, so use their declaration location. | ||
| * | ||
| * @template TObject of object | ||
| * @param ReflectionClass<TObject> $class | ||
| * @return string|array{string|false, int|false, int|false} | ||
| */ | ||
| private function classIdentity(ReflectionClass $class): string|array | ||
| { | ||
| return $class->isAnonymous() | ||
| ? [$class->getFileName(), $class->getStartLine(), $class->getEndLine()] | ||
| : $class->getName(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Known limitation. Codex recommended the same, I decided against. I'd not bypass cache by default. Given that currently everything remains cached this is already improving things. I'd leave it as is and let you, @samsonasik, decide whether custom rules at a later point might should be able to manually opt out form caching in some way (property, interface or the like).