Skip to content

Uuf6429/block_string rule breaks php-cs-fixer cache (every run is a full re-run) #18

@darthf1

Description

@darthf1

Hi!

When the Uuf6429/block_string rule is enabled with a formatter object (e.g. CliPipeFormatter) in its configuration, php-cs-fixer's signature check fails on every subsequent run, so the cache file is rewritten but never produces a hit. Removing the rule restores normal cache behaviour.

Environment

  • uuf6429/php-cs-fixer-blockstring: 1.0.5
  • friendsofphp/php-cs-fixer: 3.95.1
  • PHP: 8.5.4
  • OS: Linux (Docker)

Reproduction

.php-cs-fixer.dist.php (minimised):

use PhpCsFixer\Config;
use PhpCsFixer\Finder;
use uuf6429\PhpCsFixerBlockstring\Fixer\BlockStringFixer;
use uuf6429\PhpCsFixerBlockstring\Formatter\CliPipeFormatter;
use uuf6429\PhpCsFixerBlockstring\InterpolationCodec\GeneratedTokenCodec;

return new Config()
    ->registerCustomFixers([new BlockStringFixer()])
    ->setCacheFile(__DIR__ . '/var/cache/php-cs-fixer/.php-cs-fixer.cache')
    ->setFinder(new Finder()->in([__DIR__]))
    ->setRiskyAllowed(true)
    ->setRules([
        'Uuf6429/block_string' => [
            'formatters' => [
                'SQL' => new CliPipeFormatter(
                    ['cmd' => ['sqlfluff', '--version']],
                    ['cmd' => ['sqlfluff', 'fix', '-']],
                    new GeneratedTokenCodec(),
                    true,
                ),
            ],
        ],
    ]);

Steps:

  1. vendor/bin/php-cs-fixer fix — runs against the whole tree, writes cache file.
  2. vendor/bin/php-cs-fixer fix — should re-use the cache and skip unchanged files.

Expected: second run skips files (cache hit).
Actual: second run re-processes every file. Cache file is rewritten but never consulted.

Root cause

php-cs-fixer stores the cache as JSON (FileHandlerjson_encode($signature)), and compares the stored signature to the freshly computed one via Signature::equals(), which does an == on the rules array.

The Uuf6429/block_string rule's value contains object instances (CliPipeFormatter, GeneratedTokenCodec, deprecated bool that the constructor replaces with new DefaultNormalizer(...)). When the signature is JSON-encoded:

  • These instances don't implement JsonSerializable and don't define __serialize / jsonSerialize.
  • json_encode therefore emits {} (or the public-property projection), and json_decode returns it as a plain stdClass / array.
  • After deserialization, the rule's value is no longer the original object graph, so the == comparison against the freshly constructed CliPipeFormatter always fails.

In our cache file, the Uuf6429/block_string entry is dropped entirely — cache.json lists 144 rules but the block_string key is absent. Either way, the stored signature can never equal the runtime one.

Quick proof (after a run):

$ jq '.rules | keys | map(select(test("block_string|Uuf6429"; "i")))' var/cache/php-cs-fixer/.php-cs-fixer.cache
[]

The rule is configured, runs against every file, yet leaves no trace in the persisted signature.

Impact

  • Every CI / pre-commit run pays the full php-cs-fixer cost, even on a clean tree.
  • Disabling the rule (or stripping its formatters config) restores cache hits.

Suggested fix

The formatters embedded in the rule configuration need to participate in the signature in a way that survives json_encodejson_decode==. A few options:

  1. Implement JsonSerializable on AbstractStringFormatter / CliPipeFormatter / GeneratedTokenCodec, emitting a deterministic associative array (e.g. ['class' => static::class, 'version' => $this->version, 'formatter' => $this->formatter]). Provide a matching restoration path, or document that the rule's signature contribution is a scalar fingerprint rather than the object itself.
  2. Have BlockStringFixer expose a custom configurationDescription / signature hook that reduces the formatter graph to a stable scalar (e.g. sha1 of a normalized array) before the signature is computed.
  3. Document the limitation and refuse to register objects in the rule configuration; require users to pass an identifier/factory key that the fixer resolves internally.

Option (1) seems the smallest change and keeps the user-facing API unchanged.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions