BypassFinals Documentation

repository·master·Indexed 20 days ago

https://github.com/dg/bypass-finals

A tool for PHP developers that removes `final` and `readonly` keywords from code on-the-fly using stream wrappers, enabling the mocking of final classes and methods during testing. It features integration with PHPUnit 10+, path whitelisting and blacklisting, and a token-based removal system to maintain valid PHP syntax.

Tokens
1.9K
Snippets
6
Records
10
Agent score
19%

What's inside BypassFinals

  1. Understanding re-entrancy and cache I/O

    master

    The removeTokensCached function runs inside an active MutatingWrapper::stream_open callback. Because performing file I/O inside this callback would cause infinite recursion, the library uses a specific re-entrancy pattern.

    The Re-entrancy Pattern: All cache reads and writes must be wrapped in a single stream_wrapper_restore(...) ... finally { unregister + re-register MutatingWrapper } block. Warning: Attempting multiple stream_wrapper_restore cycles within a single callback can corrupt PHP's internal stream-wrapper state.

    Cache Details:

    • Keys: Cache files are keyed by sha1(code + tokens + CacheVersion + PHP major.minor version).
    • Atomicity: Cache writes are performed atomically using a temporary file and rename to prevent corruption from concurrent writers or interrupted processes.
    • Upgrades: If you modify the token-removal algorithm, you must increment CacheVersion to prevent stale caches from persisting.
  2. How BypassFinals wraps existing stream wrappers

    master

    BypassFinals uses a composition pattern rather than a replacement pattern. When you call enable(), it inspects the currently registered file wrapper and records it as MutatingWrapper::$underlyingWrapperClass. It then registers MutatingWrapper in its place.

    Key behaviors:

    • Composition: MutatingWrapper delegates all real file operations to the underlying wrapper via __call. This allows it to work alongside other custom stream wrappers.
    • Duck-typing: The underlying wrapper is held as a plain object, meaning any foreign wrapper that implements the streamWrapper protocol is accepted.
    • Idempotency: Calling enable() multiple times is safe; if the current wrapper is already a MutatingWrapper, the function returns early to prevent stacking wrappers.
    • Selective Modification: Modification only occurs during a rb (read-only, binary) open of a .php file that passes the isPathAllowed check. If the source code is unchanged after processing, it streams directly from the underlying handle. If changed, it serves the modified source from a tmpfile() via a NativeWrapper.
  3. How token removal rules work

    master

    BypassFinals uses token-based removal (via token_get_all(..., TOKEN_PARSE)) rather than regex to ensure precision. The goal is to strip mockability barriers while maintaining valid PHP syntax.

    Rules for final removal:

    • Strips final from classes, methods, properties, and property hooks (PHP 8.4+).
    • Exception: final ... const is preserved because final constants are a valid feature.
    • Property hooks are distinguished from properties named get or set by inspecting subsequent tokens ({, (, or =>).

    Rules for readonly removal:

    • Strips readonly before a class and before a property that retains another modifier.

    Modifier preservation (The 'at least one' rule):

    • A property must retain at least one modifier. If removal would strip all modifiers (e.g., final readonly int $x), the first removed modifier is converted to public instead of being deleted. This ensures the declaration remains valid and promoted parameters stay promoted (e.g., final readonly int $x becomes public int $x).
  4. Integrate BypassFinals with PHPUnit 10+

    master

    For PHPUnit 10 or newer, add BypassFinals as an extension in your phpunit.xml file. You can provide configuration parameters directly within the extension tag.

    <extensions>
    	<bootstrap class="DG\BypassFinals\PHPUnitExtension">
    		<parameter name="bypassFinal" value="true"/>
    		<parameter name="bypassReadOnly" value="false"/>
    		<parameter name="cacheDirectory" value="./cache"/>
    		<parameter name="allowPaths" value="*/src/*;*/lib/*"/>
    		<parameter name="denyPaths" value="*/generated/*"/>
    	</bootstrap>
    </extensions>
  5. Fix classes remaining final after enabling BypassFinals

    master

    If classes are loaded via Composer's files autoloading configuration, they are loaded during require 'vendor/autoload.php', which is often before DG\BypassFinals::enable() is called.

    To fix this, include the BypassFinals bootstrap file before your vendor autoload in your test bootstrap file:

    // tests/bootstrap.php
    require __DIR__ . '/../vendor/dg/bypass-finals/src/bootstrap.php';
    require __DIR__ . '/../vendor/autoload.php';
  6. Debug BypassFinals state with debugInfo()

    master

    If BypassFinals is not working as expected, call DG\BypassFinals::debugInfo() to output diagnostic information, including:

    • Configuration: Current settings for final and readonly stripping.
    • Startup call stack: The sequence of calls leading to enable().
    • Classes loaded before startup: A list of classes already defined in PHP (which cannot be modified).
    • Modified files: A list of files successfully processed by BypassFinals.
    DG\BypassFinals::debugInfo();
  7. Enable BypassFinals in your application

    master

    To start stripping final and readonly keywords, call DG\BypassFinals::enable().

    Important: This must be called as early as possible, ideally immediately after vendor/autoload.php is loaded. If classes are loaded before this call, they will remain final and cannot be mocked.

    DG\BypassFinals::enable();
  8. Configure BypassFinals options

    master

    You can customize the behavior of BypassFinals using the following methods:

    • Disable readonly stripping: Pass bypassReadOnly: false to enable() to keep readonly keywords intact.
    • Whitelist paths: Use allowPaths() to specify which directories or files should be processed using glob patterns.
    • Blacklist paths: Use denyPaths() to specify which paths should be ignored.
    • Set cache directory: Use setCacheDirectory() to improve performance by caching transformed files. Always use a private, trusted directory.
    // Disable readonly stripping
    DG\BypassFinals::enable(bypassReadOnly: false);
    
    // Whitelist specific paths
    DG\BypassFinals::allowPaths([
    	'*/Nette/*',
    ]);
    
    // Blacklist specific paths
    DG\BypassFinals::denyPaths([
    	'*/generated/*',
    ]);
    
    // Set a custom cache directory
    DG\BypassFinals::setCacheDirectory(__DIR__ . '/cache');
  9. Configure path filtering with isPathAllowed

    master

    The isPathAllowed function determines which files are eligible for final/readonly stripping. It uses an 'allow-then-deny' logic based on fnmatch patterns.

    • Allow list: Defaults to ['*'] (all files).
    • Deny list: A file is only checked against the deny list if it has already matched an entry in the allow list.

    Note: If a class was loaded into memory before enable() was called, it cannot be un-finalized. enable() snapshots the call stack and loaded classes to assist in diagnosing these cases via debugInfo().