From 725bb3de3ccbb7a82385594a7b6825efd06a7ebb Mon Sep 17 00:00:00 2001 From: NickSdot Date: Thu, 29 Oct 2026 13:19:48 +0700 Subject: [PATCH 1/6] feat: fingerprint evaluated architecture configuration --- src/Cache/AnalysisCacheMetadataFactory.php | 20 +- src/Cache/AnalysisResultCache.php | 2 +- src/Cache/ConfigurationFingerprint.php | 245 +++++++++++++++++++ src/Cli/AnalyseCommand.php | 9 +- src/PHPUnit/StructArmedExtension.php | 7 +- tests/Cache/AnalysisResultCacheTest.php | 21 +- tests/Cache/ConfigurationCacheTest.php | 235 ++++++++++++++++++ tests/Cache/ConfigurationFingerprintTest.php | 192 +++++++++++++++ tests/Fixtures/Cache/RuleSettings.php | 26 ++ tests/Fixtures/Cache/RuleSettingsGroup.php | 13 + 10 files changed, 753 insertions(+), 17 deletions(-) create mode 100644 src/Cache/ConfigurationFingerprint.php create mode 100644 tests/Cache/ConfigurationCacheTest.php create mode 100644 tests/Cache/ConfigurationFingerprintTest.php create mode 100644 tests/Fixtures/Cache/RuleSettings.php create mode 100644 tests/Fixtures/Cache/RuleSettingsGroup.php diff --git a/src/Cache/AnalysisCacheMetadataFactory.php b/src/Cache/AnalysisCacheMetadataFactory.php index a76833c8..d2781fbd 100644 --- a/src/Cache/AnalysisCacheMetadataFactory.php +++ b/src/Cache/AnalysisCacheMetadataFactory.php @@ -4,6 +4,7 @@ namespace Boundwize\StructArmed\Cache; +use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Version; use Composer\InstalledVersions; @@ -28,20 +29,31 @@ public function __construct(private FileHashProvider $fileHashProvider) { } + public function configurationHash(Architecture $architecture, string $configPath): string + { + return hash('xxh128', $this->fileHash($configPath) . "\0" + . (new ConfigurationFingerprint())->hash($architecture)); + } + /** * @param list $scanPaths * @param list $files * @return array */ - public function metadata(string $basePath, string $configPath, array $scanPaths, array $files): array - { + public function metadata( + string $basePath, + string $configPath, + array $scanPaths, + array $files, + string $configHash, + ): array { sort($files); return [ - 'version' => 5, + 'version' => 6, 'basePath' => $basePath, 'configPath' => $configPath, - 'configHash' => $this->fileHash($configPath), + 'configHash' => $configHash, 'composerGeneratedVersionHash' => $this->composerGeneratedVersionHash(), 'composerHash' => $this->composerHash($basePath), 'scanPaths' => $scanPaths, diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 3b1637b0..486c1d98 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -69,7 +69,7 @@ final class AnalysisResultCache * their shape, naming, or reference semantics change. It is recorded in * the metadata marker, so older caches are cleared on their next use. */ - public const FORMAT_VERSION = 17; + public const FORMAT_VERSION = 18; private readonly string $cacheDirectory; diff --git a/src/Cache/ConfigurationFingerprint.php b/src/Cache/ConfigurationFingerprint.php new file mode 100644 index 00000000..b14711e8 --- /dev/null +++ b/src/Cache/ConfigurationFingerprint.php @@ -0,0 +1,245 @@ + ['classNodeMap'], + Psr4NamespaceRule::class => ['projectBasePath', 'mappingsByBasePath', 'basePathByDirectory'], + ]; + + /** @var WeakMap> */ + private WeakMap $objects; + + private int $boundaries = 0; + + public function __construct() + { + $this->objects = new WeakMap(); + } + + public function hash(Architecture $architecture): string + { + $this->objects = new WeakMap(); + $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. + return hash('xxh128', serialize([ + $architecture->getLayers(), + $architecture->getLayerExcludePaths(), + $architecture->getLayerPatterns(), + $architecture->getRuleset(), + $architecture->getClassViolationSkips(), + $architecture->getRulesetSkipPaths(), + $architecture->getSkipPaths(), + $architecture->getRuleSkipPaths(), + $architecture->getSkippedRuleKeys(), + $rules, + ])); + } + + /** + * Only rule roots and readonly values are inspected. Mutable collaborators + * remain opaque, so service caches, parsers and connections are not traversed. + * + * @param list $ancestors + * @return list + */ + private function properties(object $object, array $ancestors, int $depth): 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]; + } + + if (isset($this->objects[$object][$depth])) { + return ['object-digest', $this->objects[$object][$depth]]; + } + + $boundaries = $this->boundaries; + $ancestors[] = $object; + $properties = []; + + do { + foreach ($class->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() !== $class->getName() || $property->isStatic()) { + 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 is part of the memo key, not the persisted identity. + if ($this->boundaries === $boundaries) { + $memo = $this->objects[$object] ?? []; + $memo[$depth] = $digest; + $this->objects[$object] = $memo; + } + + return ['object-digest', $digest]; + } + + /** + * @param list $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)) { + $class = new ReflectionClass($value); + + return $class->isReadOnly() + ? $this->properties($value, $ancestors, $depth) + : ['opaque', $this->classIdentity($class)]; + } + + 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 $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(); + } +} diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 586ce8f5..65dc1945 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -156,11 +156,12 @@ public function run(array $arguments, string $basePath): int $analysisCacheMetadataFactory = new AnalysisCacheMetadataFactory($fileHashProvider); $analysisResultCache = null; $analysisNodeCacheNamespace = ''; + $configHash = ''; // Without the cache, no file is hashed and no cache entry is read or // written, which saves that work on a one-off run over a huge codebase. if (! isset($options['no-cache'])) { - $configHash = $analysisCacheMetadataFactory->fileHash($configFile); + $configHash = $analysisCacheMetadataFactory->configurationHash($architecture, $configFile); $analysisResultCache = new AnalysisResultCache( $basePath, $fileHashProvider, @@ -194,7 +195,8 @@ public function run(array $arguments, string $basePath): int $basePath, $configFile, $scanPaths, - $files + $files, + $configHash ); $cacheKey = $analysisCacheMetadataFactory->key($metadata); $ruleViolationCollection = $analysisResultCache->load($cacheKey, $metadata); @@ -256,7 +258,8 @@ public function run(array $arguments, string $basePath): int $basePath, $configFile, $scanPaths, - $files + $files, + $configHash ); $analysisResultCache->store( $analysisCacheMetadataFactory->key($metadata), diff --git a/src/PHPUnit/StructArmedExtension.php b/src/PHPUnit/StructArmedExtension.php index 6c22c258..7489db8c 100644 --- a/src/PHPUnit/StructArmedExtension.php +++ b/src/PHPUnit/StructArmedExtension.php @@ -50,7 +50,10 @@ public function bootstrap( $start = microtime(true); $fileHashProvider = new FileHashProvider(); $analysisCacheMetadataFactory = new AnalysisCacheMetadataFactory($fileHashProvider); - $configHash = $analysisCacheMetadataFactory->fileHash($configFile); + $configHash = $analysisCacheMetadataFactory->configurationHash( + $architecture, + $configFile + ); $composerGeneratedVersionHash = $analysisCacheMetadataFactory->composerGeneratedVersionHash(); $analysisResultCache = new AnalysisResultCache( $basePath, @@ -71,7 +74,7 @@ public function bootstrap( ); $files = $analyser->filesForAnalysis($architecture); - $metadata = $analysisCacheMetadataFactory->metadata($basePath, $configFile, [], $files); + $metadata = $analysisCacheMetadataFactory->metadata($basePath, $configFile, [], $files, $configHash); $cacheKey = $analysisCacheMetadataFactory->key($metadata); $ruleViolationCollection = $analysisResultCache->load($cacheKey, $metadata); diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index cba0d7df..56ac3a26 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -2712,13 +2712,15 @@ public function testMetadataIncludesConfigAndAnalysedFiles(): void $directory, $config, ['src'], - [$source] + [$source], + 'evaluated-config-hash' ); $this->assertSame($directory, $metadata['basePath']); $this->assertSame($config, $metadata['configPath']); $this->assertSame(['src'], $metadata['scanPaths']); - $this->assertSame(5, $metadata['version']); + $this->assertSame(6, $metadata['version']); + $this->assertSame('evaluated-config-hash', $metadata['configHash']); $this->assertIsString($metadata['configHash']); $this->assertIsString($metadata['composerGeneratedVersionHash']); @@ -2773,7 +2775,8 @@ public function testMetadataIncludesComposerGeneratedVersionHash(): void $directory, $config, ['src'], - [$source] + [$source], + 'evaluated-config-hash' ); $this->assertIsString($metadata['composerGeneratedVersionHash']); @@ -2840,7 +2843,8 @@ public function testMetadataFileHashDoesNotChangeWhenOnlyFileMtimeChanges(): voi $directory, $config, ['src'], - [$source] + [$source], + 'evaluated-config-hash' ); touch($source, 1234567890); @@ -2849,7 +2853,8 @@ public function testMetadataFileHashDoesNotChangeWhenOnlyFileMtimeChanges(): voi $directory, $config, ['src'], - [$source] + [$source], + 'evaluated-config-hash' ); $this->assertSame($metadataBefore['filesHash'], $metadataAfter['filesHash']); @@ -2881,7 +2886,8 @@ public function testCacheMissesWhenComposerGeneratedVersionChanges(): void $directory, $config, ['src'], - [$source] + [$source], + 'evaluated-config-hash' ); $key = $analysisCacheMetadataFactory->key($metadataBefore); @@ -2893,7 +2899,8 @@ public function testCacheMissesWhenComposerGeneratedVersionChanges(): void $directory, $config, ['src'], - [$source] + [$source], + 'evaluated-config-hash' ); $metadataAfter['composerGeneratedVersionHash'] = 'changed'; diff --git a/tests/Cache/ConfigurationCacheTest.php b/tests/Cache/ConfigurationCacheTest.php new file mode 100644 index 00000000..ca3bb606 --- /dev/null +++ b/tests/Cache/ConfigurationCacheTest.php @@ -0,0 +1,235 @@ + */ + public static function runners(): iterable + { + yield 'CLI sequential' => ['sequential']; + yield 'CLI parallel' => ['parallel']; + yield 'PHPUnit extension' => ['extension']; + } + + #[DataProvider('runners')] + public function testEvaluatedConfigurationInvalidatesNativeCaches(string $runner): void + { + $project = $this->project(); + $rootHash = hash_file('xxh128', $project . '/structarmed.php'); + $this->settings($project, 'Allowed', 1); + + $this->assertSame(0, $this->runNative($project, $runner)[0]); + $this->assertSame(1, $this->evaluations($project)); + $this->assertNotEmpty(glob($project . '/cache/analysis-nodes-*.json')); + + // A separate PHP process constructs fresh rules; the result cache must hit. + $this->assertSame(0, $this->runNative($project, $runner)[0]); + $this->assertSame(1, $this->evaluations($project)); + + // The root and PHP source are unchanged, but the dependency changes layer. + $this->settings($project, 'Forbidden', 1); + [$exit, $output] = $this->runNative($project, $runner); + $this->assertSame(1, $exit, $output); + $this->assertStringContainsString('must not depend on', $output); + $this->assertSame(2, $this->evaluations($project)); + + // Keep layer assignments and change only an ordinary custom rule option. + $this->settings($project, 'Forbidden', 0); + [$exit, $output] = $this->runNative($project, $runner); + $this->assertSame(1, $exit, $output); + $this->assertStringContainsString('maximum allowed is 0', $output); + $this->assertSame(3, $this->evaluations($project)); + + $this->settings($project, 'Allowed', 1); + $this->assertSame(0, $this->runNative($project, $runner)[0]); + $this->assertSame(4, $this->evaluations($project)); + + $this->assertSame($rootHash, hash_file('xxh128', $project . '/structarmed.php')); + } + + public function testChangedConfiguredCallableInvalidatesTheWarmResultCache(): void + { + $project = $this->project(); + $this->settings($project, 'Allowed', 'strlen'); + $this->assertSame(0, $this->runNative($project, 'sequential')[0]); + $this->assertSame(0, $this->runNative($project, 'sequential')[0]); + $this->assertSame(1, $this->evaluations($project)); + + $this->settings($project, 'Allowed', 'intval'); + [$exit, $output] = $this->runNative($project, 'sequential'); + $this->assertSame(1, $exit, $output); + $this->assertStringContainsString('maximum allowed is 0', $output); + $this->assertSame(2, $this->evaluations($project)); + } + + public function testRootConfigEditsStillInvalidateTheWarmCache(): void + { + $project = $this->project(); + $this->settings($project, 'Allowed', 1); + $this->assertSame(0, $this->runNative($project, 'sequential')[0]); + $this->assertSame(0, $this->runNative($project, 'sequential')[0]); + $this->assertSame(1, $this->evaluations($project)); + + $config = file_get_contents($project . '/structarmed.php'); + $this->assertIsString($config); + file_put_contents($project . '/structarmed.php', $config . "\n// Root configuration edited.\n"); + $this->assertSame(0, $this->runNative($project, 'sequential')[0]); + $this->assertSame(2, $this->evaluations($project)); + } + + private function project(): string + { + $project = $this->makeTemporaryDirectory('structarmed-config-cache'); + mkdir($project . '/src'); + file_put_contents($project . '/composer.json', '{"autoload":{"psr-4":{"App\\\\":"src/"}}}'); + file_put_contents($project . '/src/Consumer.php', <<<'PHP_SOURCE' +cacheDirectory('cache') + ->layer('Source', 'src/Consumer.php') + ->layer($settings['layer'], 'src/Dependency.php') + ->rule('deny', new MayNotDependOnRule('Source', 'Forbidden')) + ->rule('count', new ConfiguredCountRule( + is_string($settings['max']) ? Closure::fromCallable($settings['max']) : $settings['max'] + )) + ->rule('probe', new ProbeRule(__DIR__)); +CONFIG); + file_put_contents($project . '/ProbeRule.php', <<<'RULE' +isInLayer('Source'); + } + public function evaluate(\Boundwize\StructArmed\Analyser\ClassNode $node): ?RuleViolation + { + $maximum = $this->maximum instanceof Closure ? ($this->maximum)('0') : $this->maximum; + return (new \Boundwize\StructArmed\Rule\Rules\Class_\MaxDependencyCountRule('Source', $maximum)) + ->evaluate($node); + } +} +final class ProbeRule implements ProjectRuleInterface +{ + private Closure $service; + public function __construct(private string $directory) { $this->service = static fn() => null; } + public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation + { + file_put_contents($this->directory . '/evaluations', '.', FILE_APPEND); + return null; + } +} +RULE); + file_put_contents($project . '/extension.php', <<<'EXTENSION' +newInstanceWithoutConstructor(); +$reflection->getProperty('noProgress')->setValue($configuration, true); +putenv('STRUCTARMED_DISABLED'); +try { + (new StructArmedExtension())->bootstrap($configuration, new Facade(), ParameterCollection::fromArray([ + 'config' => __DIR__ . '/structarmed.php', + ])); +} catch (ViolationsFoundException) { + exit(1); +} +EXTENSION); + + return $project; + } + + private function settings(string $project, string $layer, int|string $maximum): void + { + file_put_contents( + $project . '/settings.php', + ' $layer, 'max' => $maximum], true) . ';' + ); + } + + private function evaluations(string $project): int + { + $contents = file_get_contents($project . '/evaluations'); + $this->assertIsString($contents); + + return strlen($contents); + } + + /** + * @phpstan-impure Executes another PHP process and mutates its project cache. + * @return array{int, string} + */ + private function runNative(string $project, string $runner): array + { + $root = dirname(__DIR__, 2); + $command = $runner === 'extension' + ? [PHP_BINARY, $project . '/extension.php', $root . '/vendor/autoload.php'] + : [PHP_BINARY, $root . '/bin/structarmed', 'analyse', '--no-progress']; + + if ($runner === 'sequential') { + $command[] = '--disable-parallel'; + } + + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $project); + $this->assertIsResource($process); + $output = stream_get_contents($pipes[1]); + $output .= stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $output]; + } +} diff --git a/tests/Cache/ConfigurationFingerprintTest.php b/tests/Cache/ConfigurationFingerprintTest.php new file mode 100644 index 00000000..b599836b --- /dev/null +++ b/tests/Cache/ConfigurationFingerprintTest.php @@ -0,0 +1,192 @@ + Architecture::define()->withPresets(Preset::PER(), Preset::YAGNI()); + + $this->assertSame($factory->hash($build()), $factory->hash($build())); + } + + public function testLocationAndBaselineAreNotEvaluationInputs(): void + { + $factory = new ConfigurationFingerprint(); + $architecture = Architecture::define()->layer('Source', 'src/'); + $before = $factory->hash($architecture); + + $architecture->cacheDirectory('different/cache')->baseline('different/baseline.json'); + + $this->assertSame($before, $factory->hash($architecture)); + } + + public function testCustomRulesUseTheSameDeterministicIdentity(): void + { + $factory = new ConfigurationFingerprint(); + $rule = $this->createStub(RuleInterface::class); + $architecture = Architecture::define()->rule('custom', $rule); + + $this->assertSame($factory->hash($architecture), $factory->hash(clone $architecture)); + $this->assertNotSame($factory->hash($architecture), $factory->hash((clone $architecture)->skipRule('custom'))); + } + + public function testRuleOptionsAndLayerPrecedenceContributeToIdentity(): void + { + $factory = new ConfigurationFingerprint(); + $architecture = Architecture::define()->layer('One', 'src/')->layer('Two', 'src/'); + $before = $factory->hash($architecture->rule('final', new MustBeFinalRule('One'))); + + $this->assertNotSame($before, $factory->hash($architecture->replaceRule('final', new MustBeFinalRule('Two')))); + $this->assertNotSame( + $factory->hash(Architecture::define()->layer('One', 'src/')->layer('Two', 'src/')), + $factory->hash(Architecture::define()->layer('Two', 'src/')->layer('One', 'src/')) + ); + } + + public function testReadonlySettingsAndClosureCapturesContributeTheirValues(): void + { + $factory = new ConfigurationFingerprint(); + $this->assertSame( + $factory->hash($this->withSettings(new RuleSettings(1))), + $factory->hash($this->withSettings(new RuleSettings(1))) + ); + $this->assertNotSame( + $factory->hash($this->withSettings(new RuleSettings(1))), + $factory->hash($this->withSettings(new RuleSettings(2))) + ); + + $callback = static fn(int $maximum): Closure => static fn(): int => $maximum; + $this->assertNotSame( + $factory->hash($this->withSettings($callback(1))), + $factory->hash($this->withSettings($callback(2))) + ); + } + + public function testOpaqueCollaboratorsAndResourcesHaveStableIdentities(): void + { + $factory = new ConfigurationFingerprint(); + $one = new stdClass(); + $two = new stdClass(); + $one->runtimeMemo = 'one'; + $two->runtimeMemo = 'two'; + $first = fopen('php://temp', 'w+'); + $second = fopen('php://temp', 'w+'); + $this->assertIsResource($first); + $this->assertIsResource($second); + + try { + $this->assertSame( + $factory->hash($this->withSettings([$one, $first])), + $factory->hash($this->withSettings([$two, $second])) + ); + } finally { + fclose($first); + fclose($second); + } + } + + public function testNativeInjectedAndMemoizedStateDoesNotChangeIdentity(): void + { + $factory = new ConfigurationFingerprint(); + $deny = new MayNotDependOnRule('One', 'Two'); + $psr4 = new Psr4NamespaceRule('Source'); + $architecture = Architecture::define()->rule('deny', $deny)->rule('psr4', $psr4); + $before = $factory->hash($architecture); + + $deny->injectClassNodeMap([ + 'Example' => new ClassNode('Example', __FILE__, 1, 'Two', null, false, true, false, false), + ]); + $psr4->evaluateProject(__DIR__, $architecture); + + $this->assertSame($before, $factory->hash($architecture)); + } + + public function testFirstClassCallablesAndReadonlyBoundStateHaveDistinctIdentity(): void + { + $factory = new ConfigurationFingerprint(); + + $this->assertNotSame( + $factory->hash($this->withSettings(strtolower(...))), + $factory->hash($this->withSettings(strtoupper(...))) + ); + $this->assertNotSame( + $factory->hash($this->withSettings((new RuleSettings(1))->callback())), + $factory->hash($this->withSettings((new RuleSettings(2))->callback())) + ); + $this->assertSame( + $factory->hash($this->withSettings((new RuleSettings(1))->callback())), + $factory->hash($this->withSettings((new RuleSettings(1))->callback())) + ); + } + + public function testSharedAndDuplicatedReadonlyGraphsHaveTheSameIdentity(): void + { + $factory = new ConfigurationFingerprint(); + $shared = new RuleSettingsGroup([new RuleSettings(1), new RuleSettings(2)]); + $copy = static fn(): RuleSettingsGroup => new RuleSettingsGroup([new RuleSettings(1), new RuleSettings(2)]); + + $this->assertSame( + $factory->hash($this->withSettings([$shared, $shared, ['nested' => $shared]])), + $factory->hash($this->withSettings([$copy(), $copy(), ['nested' => $copy()]])) + ); + + // Reusing a shallow digest at a deeper boundary must not change semantics. + $deepShared = $shared; + $deepCopy = $copy(); + for ($depth = 0; $depth < 27; $depth++) { + $deepShared = [$deepShared]; + $deepCopy = [$deepCopy]; + } + + $this->assertSame( + $factory->hash($this->withSettings([$shared, $deepShared])), + $factory->hash($this->withSettings([$copy(), $deepCopy])) + ); + } + + private function withSettings(mixed $settings): Architecture + { + return Architecture::define()->rule('custom', new class ($settings) implements RuleInterface { + public function __construct(private readonly mixed $settings) + { + } + + public function appliesTo(ClassNode $classNode): bool + { + return $this->settings !== null; + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + return null; + } + }); + } +} diff --git a/tests/Fixtures/Cache/RuleSettings.php b/tests/Fixtures/Cache/RuleSettings.php new file mode 100644 index 00000000..57b124a8 --- /dev/null +++ b/tests/Fixtures/Cache/RuleSettings.php @@ -0,0 +1,26 @@ + $this->maximum; + } + + /** @return array */ + public function __serialize(): array + { + // A serializer would discard the option; fingerprinting must read stored values. + return []; + } +} diff --git a/tests/Fixtures/Cache/RuleSettingsGroup.php b/tests/Fixtures/Cache/RuleSettingsGroup.php new file mode 100644 index 00000000..ad76ddf3 --- /dev/null +++ b/tests/Fixtures/Cache/RuleSettingsGroup.php @@ -0,0 +1,13 @@ + $settings */ + public function __construct(public array $settings) + { + } +} From 66c50f99ec5b3522274640c749bc78852b27be2e Mon Sep 17 00:00:00 2001 From: NickSdot Date: Thu, 29 Oct 2026 20:13:35 +0700 Subject: [PATCH 2/6] review: replace WeakMap with SplObjectStorage --- src/Cache/ConfigurationFingerprint.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Cache/ConfigurationFingerprint.php b/src/Cache/ConfigurationFingerprint.php index b14711e8..75d1ff20 100644 --- a/src/Cache/ConfigurationFingerprint.php +++ b/src/Cache/ConfigurationFingerprint.php @@ -11,8 +11,8 @@ use DateTimeImmutable; use ReflectionClass; use ReflectionFunction; +use SplObjectStorage; use UnitEnum; -use WeakMap; use function get_resource_type; use function gettype; @@ -40,19 +40,19 @@ final class ConfigurationFingerprint Psr4NamespaceRule::class => ['projectBasePath', 'mappingsByBasePath', 'basePathByDirectory'], ]; - /** @var WeakMap> */ - private WeakMap $objects; + /** @var SplObjectStorage> */ + private SplObjectStorage $objects; private int $boundaries = 0; public function __construct() { - $this->objects = new WeakMap(); + $this->objects = new SplObjectStorage(); } public function hash(Architecture $architecture): string { - $this->objects = new WeakMap(); + $this->objects = new SplObjectStorage(); $this->boundaries = 0; $rules = []; @@ -64,7 +64,7 @@ public function hash(Architecture $architecture): string // Registration order affects precedence. Baselines are applied after cached // evaluation; the cache directory is a storage location, not an evaluation input. - return hash('xxh128', serialize([ + $fingerprint = hash('xxh128', serialize([ $architecture->getLayers(), $architecture->getLayerExcludePaths(), $architecture->getLayerPatterns(), @@ -76,6 +76,11 @@ public function hash(Architecture $architecture): string $architecture->getSkippedRuleKeys(), $rules, ])); + + // SplObjectStorage holds strong references; release the graph after this run. + $this->objects = new SplObjectStorage(); + + return $fingerprint; } /** From ef6b270007c1781c670d390d93affa6718851b95 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Thu, 29 Oct 2026 20:13:37 +0700 Subject: [PATCH 3/6] review: construct fingerprint in metadata factory constructor --- src/Cache/AnalysisCacheMetadataFactory.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Cache/AnalysisCacheMetadataFactory.php b/src/Cache/AnalysisCacheMetadataFactory.php index d2781fbd..6ffc6634 100644 --- a/src/Cache/AnalysisCacheMetadataFactory.php +++ b/src/Cache/AnalysisCacheMetadataFactory.php @@ -25,14 +25,16 @@ */ final readonly class AnalysisCacheMetadataFactory { - public function __construct(private FileHashProvider $fileHashProvider) - { + public function __construct( + private FileHashProvider $fileHashProvider, + private ConfigurationFingerprint $configurationFingerprint = new ConfigurationFingerprint(), + ) { } public function configurationHash(Architecture $architecture, string $configPath): string { return hash('xxh128', $this->fileHash($configPath) . "\0" - . (new ConfigurationFingerprint())->hash($architecture)); + . $this->configurationFingerprint->hash($architecture)); } /** From 4705d9258b86308016342168d48bc6cb2e677932 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Thu, 29 Oct 2026 20:21:05 +0700 Subject: [PATCH 4/6] review: improve test coverage --- .../AnalysisCacheMetadataFactoryTest.php | 38 ++++++++++++++++++ tests/Cache/ConfigurationFingerprintTest.php | 40 +++++++++++++++++++ tests/Fixtures/Cache/CyclicRuleSettings.php | 15 +++++++ tests/Fixtures/Cache/RulePolicy.php | 11 +++++ 4 files changed, 104 insertions(+) create mode 100644 tests/Cache/AnalysisCacheMetadataFactoryTest.php create mode 100644 tests/Fixtures/Cache/CyclicRuleSettings.php create mode 100644 tests/Fixtures/Cache/RulePolicy.php diff --git a/tests/Cache/AnalysisCacheMetadataFactoryTest.php b/tests/Cache/AnalysisCacheMetadataFactoryTest.php new file mode 100644 index 00000000..36030ff3 --- /dev/null +++ b/tests/Cache/AnalysisCacheMetadataFactoryTest.php @@ -0,0 +1,38 @@ +makeTemporaryFile('structarmed-config-hash'); + file_put_contents($config, 'layer('Domain', 'src/Domain/'); + $equivalent = Architecture::define()->layer('Domain', 'src/Domain/'); + $changed = Architecture::define()->layer('Domain', 'src/Changed/'); + $before = $factory->configurationHash($one, $config); + + $this->assertSame($before, $factory->configurationHash($equivalent, $config)); + $this->assertNotSame($before, $factory->configurationHash($changed, $config)); + + file_put_contents($config, 'assertNotSame($before, $nextRun->configurationHash($one, $config)); + } +} diff --git a/tests/Cache/ConfigurationFingerprintTest.php b/tests/Cache/ConfigurationFingerprintTest.php index b599836b..f4144ff0 100644 --- a/tests/Cache/ConfigurationFingerprintTest.php +++ b/tests/Cache/ConfigurationFingerprintTest.php @@ -13,9 +13,12 @@ use Boundwize\StructArmed\Rule\Rules\Composer\Psr4NamespaceRule; use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\RuleViolation; +use Boundwize\StructArmed\Tests\Fixtures\Cache\CyclicRuleSettings; +use Boundwize\StructArmed\Tests\Fixtures\Cache\RulePolicy; use Boundwize\StructArmed\Tests\Fixtures\Cache\RuleSettings; use Boundwize\StructArmed\Tests\Fixtures\Cache\RuleSettingsGroup; use Closure; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use stdClass; @@ -171,6 +174,43 @@ public function testSharedAndDuplicatedReadonlyGraphsHaveTheSameIdentity(): void ); } + public function testEnumAndImmutableDateSettingsContributeTheirValues(): void + { + $factory = new ConfigurationFingerprint(); + $this->assertNotSame( + $factory->hash($this->withSettings(RulePolicy::Strict)), + $factory->hash($this->withSettings(RulePolicy::Relaxed)) + ); + $one = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $equivalent = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $changed = new DateTimeImmutable('2026-01-02T00:00:00+00:00'); + $this->assertSame( + $factory->hash($this->withSettings($one)), + $factory->hash($this->withSettings($equivalent)) + ); + $this->assertNotSame( + $factory->hash($this->withSettings($one)), + $factory->hash($this->withSettings($changed)) + ); + } + + public function testCyclicSettingsTerminateDeterministicallyWithoutHidingScalarChanges(): void + { + $factory = new ConfigurationFingerprint(); + $one = new CyclicRuleSettings(1); + $equivalent = new CyclicRuleSettings(1); + $changed = new CyclicRuleSettings(2); + + $this->assertSame( + $factory->hash($this->withSettings($one)), + $factory->hash($this->withSettings($equivalent)) + ); + $this->assertNotSame( + $factory->hash($this->withSettings($one)), + $factory->hash($this->withSettings($changed)) + ); + } + private function withSettings(mixed $settings): Architecture { return Architecture::define()->rule('custom', new class ($settings) implements RuleInterface { diff --git a/tests/Fixtures/Cache/CyclicRuleSettings.php b/tests/Fixtures/Cache/CyclicRuleSettings.php new file mode 100644 index 00000000..f2b6e08f --- /dev/null +++ b/tests/Fixtures/Cache/CyclicRuleSettings.php @@ -0,0 +1,15 @@ +next = $this; + } +} diff --git a/tests/Fixtures/Cache/RulePolicy.php b/tests/Fixtures/Cache/RulePolicy.php new file mode 100644 index 00000000..10c6f91e --- /dev/null +++ b/tests/Fixtures/Cache/RulePolicy.php @@ -0,0 +1,11 @@ + Date: Thu, 29 Oct 2026 20:33:30 +0700 Subject: [PATCH 5/6] review: added e2e test --- tests/Analyser/AnalyserTest.php | 161 ++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index ad5afbf9..fd457138 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -13,8 +13,11 @@ use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; use Boundwize\StructArmed\Architecture; +use Boundwize\StructArmed\Cache\AnalysisCacheMetadataFactory; use Boundwize\StructArmed\Cache\AnalysisResultCache; +use Boundwize\StructArmed\Cache\ConfigurationFingerprint; use Boundwize\StructArmed\Cache\FileHashProvider; +use Boundwize\StructArmed\Config\ConfigLoader; use Boundwize\StructArmed\File\PhpFileCollector; use Boundwize\StructArmed\File\SkipPathMatcher; use Boundwize\StructArmed\Preset\Preset; @@ -59,11 +62,14 @@ use function array_values; use function count; use function dirname; +use function file_get_contents; use function file_put_contents; use function gc_disable; use function gc_enable; use function gc_enabled; +use function glob; use function is_dir; +use function json_encode; use function mkdir; use function realpath; use function rename; @@ -72,10 +78,15 @@ use function str_replace; use function symlink; use function unlink; +use function var_export; use const DIRECTORY_SEPARATOR; #[CoversClass(Analyser::class)] +#[CoversClass(AnalysisCacheMetadataFactory::class)] +#[CoversClass(AnalysisResultCache::class)] +#[CoversClass(ConfigurationFingerprint::class)] +#[CoversClass(ConfigLoader::class)] #[CoversClass(ParallelAnalysisNodeExtractor::class)] #[CoversClass(PhpFileCollector::class)] #[CoversClass(SkipPathMatcher::class)] @@ -83,6 +94,156 @@ final class AnalyserTest extends TestCase { use TemporaryDirectoryCleanupTrait; + public function testIncludedConfigurationChangesInvalidateBothCaches(): void + { + $basePath = $this->makeTemporaryDirectory('library-architecture'); + + mkdir($basePath . '/src/Catalog', 0777, true); + mkdir($basePath . '/src/Reminders', 0777, true); + mkdir($basePath . '/src/Notifications', 0777, true); + + file_put_contents($basePath . '/composer.json', json_encode([ + 'autoload' => ['psr-4' => ['Library\\' => 'src/']], + ])); + + file_put_contents($basePath . '/src/Catalog/BookCatalog.php', <<<'PHP' +cacheDirectory('cache') + ->layer('Catalog', $settings['catalog']) + ->layer('Notifications', $settings['notifications']) + ->rule( + 'catalog.no_notifications', + new MayNotDependOnRule('Catalog', 'Notifications') + ); +PHP); + + $rootContents = file_get_contents($configFile); + $this->assertIsString($rootContents); + + /** @return array{RuleViolationCollection, bool} */ + $run = static function () use ($basePath, $configFile): array { + $architecture = ConfigLoader::load($configFile); + $hashes = new FileHashProvider(); + $factory = new AnalysisCacheMetadataFactory($hashes); + $configHash = $factory->configurationHash($architecture, $configFile); + + $cache = new AnalysisResultCache( + $basePath, + $hashes, + $architecture->getCacheDirectory(), + $configHash, + $factory->composerGeneratedVersionHash() + ); + + if ($cache->shouldInvalidate()) { + $cache->clear(); + } + + $analyser = new Analyser( + $basePath, + $cache, + $factory->analysisNodeCacheNamespace($basePath, $configHash) + ); + + $files = $analyser->filesForAnalysis($architecture); + $metadata = $factory->metadata($basePath, $configFile, [], $files, $configHash); + $key = $factory->key($metadata); + + $violations = $cache->load($key, $metadata); + + if ($violations instanceof RuleViolationCollection) { + return [$violations, true]; + } + + $violations = $analyser->analyse( + $architecture, + analyserOptions: AnalyserOptions::sequential(), + files: $files + ); + + $cache->store($key, $metadata, $violations); + + return [$violations, false]; + }; + + // Initially, both classes belong to Catalog. + file_put_contents($settingsFile, ' ['src/Catalog/', 'src/Reminders/'], + 'notifications' => ['src/Notifications/'], + ], true) . ';'); + + [$violations, $cacheHit] = $run(); + + $this->assertFalse($cacheHit); + $this->assertTrue($violations->isEmpty()); + $this->assertNotEmpty(glob($basePath . '/cache/analysis-nodes-*.json')); + + [$violations, $cacheHit] = $run(); + + $this->assertTrue($cacheHit); + $this->assertTrue($violations->isEmpty()); + + // Change only the included settings: reminders now belong to Notifications. + file_put_contents($settingsFile, ' ['src/Catalog/'], + 'notifications' => ['src/Notifications/', 'src/Reminders/'], + ], true) . ';'); + + $this->assertSame($rootContents, file_get_contents($configFile)); + + [$violations, $cacheHit] = $run(); + + $this->assertFalse($cacheHit); + $this->assertCount(1, $violations); + + $dependencyViolations = $violations->forRule('catalog.no_notifications'); + + $this->assertCount(1, $dependencyViolations); + $this->assertStringContainsString('must not depend on', $dependencyViolations[0]->message); + $this->assertStringContainsString('Notifications', $dependencyViolations[0]->message); + + [$cachedViolations, $cacheHit] = $run(); + + $this->assertTrue($cacheHit); + $this->assertSame($violations->toArray(), $cachedViolations->toArray()); + } + /** * A rule flagging every named function and every anonymous function that * accesses a superglobal, implementing both function-like interfaces. From 0c819aae90f8be1d3d08266985da203314d3d260 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Thu, 29 Oct 2026 21:05:34 +0700 Subject: [PATCH 6/6] review: fix readonly settings and nested rule fingerprints --- src/Cache/ConfigurationFingerprint.php | 37 +++++++++----- tests/Cache/ConfigurationFingerprintTest.php | 48 +++++++++++++++++++ .../Cache/PartiallyReadonlyRuleSettings.php | 14 ++++++ 3 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 tests/Fixtures/Cache/PartiallyReadonlyRuleSettings.php diff --git a/src/Cache/ConfigurationFingerprint.php b/src/Cache/ConfigurationFingerprint.php index 75d1ff20..8827d983 100644 --- a/src/Cache/ConfigurationFingerprint.php +++ b/src/Cache/ConfigurationFingerprint.php @@ -6,6 +6,11 @@ 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; @@ -40,7 +45,7 @@ final class ConfigurationFingerprint Psr4NamespaceRule::class => ['projectBasePath', 'mappingsByBasePath', 'basePathByDirectory'], ]; - /** @var SplObjectStorage> */ + /** @var SplObjectStorage> */ private SplObjectStorage $objects; private int $boundaries = 0; @@ -84,13 +89,13 @@ public function hash(Architecture $architecture): string } /** - * Only rule roots and readonly values are inspected. Mutable collaborators - * remain opaque, so service caches, parsers and connections are not traversed. + * Rules contribute configured state, excluding known injected runtime fields. + * Other objects contribute only readonly properties, leaving mutable service state opaque. * * @param list $ancestors * @return list */ - private function properties(object $object, array $ancestors, int $depth): array + private function properties(object $object, array $ancestors, int $depth, bool $readonlyOnly = false): array { $class = new ReflectionClass($object); $identity = $this->classIdentity($class); @@ -106,8 +111,10 @@ private function properties(object $object, array $ancestors, int $depth): array return ['boundary', $identity]; } - if (isset($this->objects[$object][$depth])) { - return ['object-digest', $this->objects[$object][$depth]]; + $memoKey = $depth . ':' . ($readonlyOnly ? 'readonly' : 'all'); + + if (isset($this->objects[$object][$memoKey])) { + return ['object-digest', $this->objects[$object][$memoKey]]; } $boundaries = $this->boundaries; @@ -120,6 +127,10 @@ private function properties(object $object, array $ancestors, int $depth): array continue; } + if ($readonlyOnly && ! $property->isReadOnly()) { + continue; + } + $name = $property->getName(); if (in_array($name, self::RUNTIME_PROPERTIES[$class->getName()] ?? [], true)) { @@ -152,10 +163,10 @@ private function properties(object $object, array $ancestors, int $depth): array $digest = hash('xxh128', serialize(['object', $identity, $properties])); // Cycles and depth cutoffs depend on the current traversal path. Never - // memoize them. Depth is part of the memo key, not the persisted identity. + // memoize them. Depth and property selection belong to the memo key, not the persisted identity. if ($this->boundaries === $boundaries) { $memo = $this->objects[$object] ?? []; - $memo[$depth] = $digest; + $memo[$memoKey] = $digest; $this->objects[$object] = $memo; } @@ -214,11 +225,13 @@ private function value(mixed $value, array $ancestors, int $depth): mixed } if (is_object($value)) { - $class = new ReflectionClass($value); + $isRule = $value instanceof RuleInterface + || $value instanceof ProjectRuleInterface + || $value instanceof FunctionRuleInterface + || $value instanceof AnonymousFunctionRuleInterface + || $value instanceof AnonymousClassRuleInterface; - return $class->isReadOnly() - ? $this->properties($value, $ancestors, $depth) - : ['opaque', $this->classIdentity($class)]; + return $this->properties($value, $ancestors, $depth, readonlyOnly: ! $isRule); } if (is_resource($value)) { diff --git a/tests/Cache/ConfigurationFingerprintTest.php b/tests/Cache/ConfigurationFingerprintTest.php index f4144ff0..5cf23986 100644 --- a/tests/Cache/ConfigurationFingerprintTest.php +++ b/tests/Cache/ConfigurationFingerprintTest.php @@ -14,6 +14,7 @@ use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Tests\Fixtures\Cache\CyclicRuleSettings; +use Boundwize\StructArmed\Tests\Fixtures\Cache\PartiallyReadonlyRuleSettings; use Boundwize\StructArmed\Tests\Fixtures\Cache\RulePolicy; use Boundwize\StructArmed\Tests\Fixtures\Cache\RuleSettings; use Boundwize\StructArmed\Tests\Fixtures\Cache\RuleSettingsGroup; @@ -211,6 +212,53 @@ public function testCyclicSettingsTerminateDeterministicallyWithoutHidingScalarC ); } + public function testReadonlyPropertiesOfOrdinarySettingsObjectsContributeTheirValues(): void + { + $factory = new ConfigurationFingerprint(); + + $one = new PartiallyReadonlyRuleSettings(1); + $equivalent = new PartiallyReadonlyRuleSettings(1); + $changed = new PartiallyReadonlyRuleSettings(2); + + $one->runtimeCounter = 10; + $equivalent->runtimeCounter = 20; + + $this->assertSame( + $factory->hash($this->withSettings($one)), + $factory->hash($this->withSettings($equivalent)) + ); + + $this->assertNotSame( + $factory->hash($this->withSettings($one)), + $factory->hash($this->withSettings($changed)) + ); + } + + public function testNestedRulesContributeOptionsWithoutInjectedLayerMaps(): void + { + $factory = new ConfigurationFingerprint(); + + $nested = new MayNotDependOnRule('Catalog', 'Notifications'); + + $before = $factory->hash($this->withSettings($nested)); + + $nested->injectClassNodeMap([ + 'Example' => new ClassNode('Example', __FILE__, 1, 'Notifications', null, false, true, false, false), + ]); + + $this->assertSame($before, $factory->hash($this->withSettings($nested))); + + $this->assertSame( + $before, + $factory->hash($this->withSettings(new MayNotDependOnRule('Catalog', 'Notifications'))) + ); + + $this->assertNotSame( + $before, + $factory->hash($this->withSettings(new MayNotDependOnRule('Catalog', 'Billing'))) + ); + } + private function withSettings(mixed $settings): Architecture { return Architecture::define()->rule('custom', new class ($settings) implements RuleInterface { diff --git a/tests/Fixtures/Cache/PartiallyReadonlyRuleSettings.php b/tests/Fixtures/Cache/PartiallyReadonlyRuleSettings.php new file mode 100644 index 00000000..cfef3ca2 --- /dev/null +++ b/tests/Fixtures/Cache/PartiallyReadonlyRuleSettings.php @@ -0,0 +1,14 @@ +