\Drupal\strata\Code VendorDriftDetector

Notices when `vendor/` stops matching the lockfile that is supposed to reproduce it.

Referencing composer.lock instead of storing 241 MB of dependencies rests on one assumption: that running composer install against that lock reproduces the tree. That assumption is true right up until someone patches a dependency in place, and then the backup is quietly missing the only copy of a change the site depends on.

So the lock is stored WITH a fingerprint of the tree it produced, and the fingerprint is checked. When it moves without the lock moving, code.vendor_drift fires and the files that drifted are captured as bytes - only those files, so the cost is the patch rather than the tree.

The fingerprint is a bounded sample, and that is stated rather than implied. Hashing 241 MB on every cron would cost more than the realm it protects, so a fixed number of files is sampled in a deterministic order. A patch to a file outside the sample is not seen by the fingerprint; what catches that is composer.lock itself changing, or an operator running a full check. The sample size is the dial between cost and confidence.

Summary

Methods
Properties
Constants
__construct
isApplicable
fingerprint
compare
drifted
descriptions
sampleSize
No public properties found
DRIFT
DEFAULT_SAMPLE
HASHED_EXTENSIONS
No protected methods found
No protected properties found
No protected constants found
sample
describe
walk
vendor
lockPath
relative
root
sampleSize
No private constants found

Constant

DRIFT

DRIFT = 'code.vendor_drift'

Code raised when the tree and the lock disagree.

DEFAULT_SAMPLE

DEFAULT_SAMPLE = 400

How many files the fingerprint covers by default.

A full walk of a Drupal vendor/ is tens of thousands of files. At this sample a check costs a few hundred stat calls and reads, which fits inside a cron window.

HASHED_EXTENSIONS

HASHED_EXTENSIONS = ['php', 'inc', 'module', 'yml', 'twig']

Files whose content is hashed rather than just measured.

A size and a timestamp catch a replaced file; only content catches a patch that kept the size the same, which is exactly what a one-line fix does.

Properties

$root

$root : string

Type

string

$sampleSize

$sampleSize : int

Type

int

Methods

__construct()

__construct(string  $root, int  $sampleSize = \self::DEFAULT_SAMPLE) : mixed

Constructs a detector.

Parameters

string $root

The project root holding both composer.lock and vendor/.

int $sampleSize

How many files the fingerprint covers.

Returns

mixed —

isApplicable()

isApplicable() : bool

Whether there is a `vendor/` to check.

Returns

bool —

FALSE on a site installed without composer, where there is nothing to drift.

fingerprint()

fingerprint() : string

A fingerprint of the current tree.

Returns

string —

The fingerprint, or an empty string when there is no tree to fingerprint.

compare()

compare(string  $expected, string  $lockDigest) : array{drifted: bool, reason: string, lockChanged: bool, fingerprint: string}

Compares the tree against a fingerprint taken earlier.

Parameters

string $expected

The stored fingerprint.

string $lockDigest

The stored digest of composer.lock.

Returns

array{drifted: bool, reason: string, lockChanged: bool, fingerprint: string} —

Whether the tree drifted, why, whether the lock itself moved, and the current fingerprint.

drifted()

drifted(array  $expected) : list<string>

The files whose content differs from what the fingerprint recorded.

Called only after drift is detected, and only over the sample, so the answer is what to capture rather than a complete account of the tree.

Parameters

array $expected

Path keyed to the description recorded earlier.

Returns

list

Paths relative to the project root.

descriptions()

descriptions() : array<string,string>

The per-file descriptions the fingerprint is built from.

Stored alongside the fingerprint so VendorDriftDetector::drifted() can say which files moved rather than only that something did.

Returns

array

Path keyed to its description.

sampleSize()

sampleSize() : int

How many files the fingerprint covers.

Returns

int —

The sample size.

sample()

sample() : array<string,\SplFileInfo>

The sampled files, in a deterministic order.

Sorted by path and truncated, so two runs on the same tree sample the same files and a difference means the tree moved rather than the iteration order did.

Returns

array

Path relative to the project root, keyed to the file.

describe()

describe(string  $path, \SplFileInfo  $file) : string

A short description of one file, for the fingerprint.

Parameters

string $path

Path relative to the project root.

\SplFileInfo $file

The file.

Returns

string —

Its size and, for source files, a digest of its content.

walk()

walk() : \Generator<int,\SplFileInfo>

Walks the vendor tree.

Returns

\Generator

The files found.

vendor()

vendor() : string

The vendor directory.

Returns

string —

Its absolute path.

lockPath()

lockPath() : string

The lockfile path.

Returns

string —

Its absolute path.

relative()

relative(string  $absolute) : string

A path relative to the project root.

Parameters

string $absolute

The absolute path.

Returns

string —

The relative path, or an empty string when it is not under the root.