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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions src/Cache/AnalysisCacheMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Boundwize\StructArmed\Cache;

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Version;
use Composer\InstalledVersions;

Expand All @@ -24,24 +25,37 @@
*/
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"
. $this->configurationFingerprint->hash($architecture));
}

/**
* @param list<string> $scanPaths
* @param list<string> $files
* @return array<string, mixed>
*/
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,
Expand Down
2 changes: 1 addition & 1 deletion src/Cache/AnalysisResultCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
263 changes: 263 additions & 0 deletions src/Cache/ConfigurationFingerprint.php
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(),
Comment on lines +220 to +222

Copy link
Copy Markdown
Author

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).

$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();
}
}
9 changes: 6 additions & 3 deletions src/Cli/AnalyseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -256,7 +258,8 @@ public function run(array $arguments, string $basePath): int
$basePath,
$configFile,
$scanPaths,
$files
$files,
$configHash
);
$analysisResultCache->store(
$analysisCacheMetadataFactory->key($metadata),
Expand Down
7 changes: 5 additions & 2 deletions src/PHPUnit/StructArmedExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Loading
Loading