refactor: ValueCleaner is the named choke point (H1)

The choke point moves out of AttrWriter into ValueCleaner (catalog, the
composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue),
kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with
VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits
on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged.
- ValueCleaner's docblock is the one canonical description: steps 0-8 with their
  rulings in code order, invariants I1-I3; the step comments in cleanNode carry
  the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks
  point there and keep only their own rules.
- ValueCleaner::textReplacementFor($module) holds the D10 rule that was a
  closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind).
- AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition
  builds one per composition (cleaner()).
Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf
moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner());
mixed tests call the cleaner in place; +1 test for textReplacementFor. Every
provider row and per-test assertion count unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 18:11:28 +02:00
co-authored by Claude Opus 5.5
parent 203d14a2f9
commit 62ce99e558
13 changed files with 552 additions and 447 deletions
@@ -30,7 +30,7 @@ use WP_Error;
* it is written; DiviWriter::save() stores it with its own restore-point revision and change
* log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged
* and reported there, the others go on. Inserted text is cleaned by the field kind through
* AttrWriter::cleanValue() (S2/S28); URL and code fields are never touched.
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
*/
final class DiviReplaceTextAbility implements Ability
{
@@ -379,7 +379,7 @@ final class DiviReplaceTextAbility implements Ability
/**
* Every hit of $page, computed without changing it. The replacement is cleaned per place by
* the entry's AttrWriter (field kind; markers rejected); URL and code fields are skipped.
* the entry's ValueCleaner (field kind; markers rejected); URL and code fields are skipped.
*
* @return array{count: int, samples: list<array<string, string>>,
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
@@ -387,7 +387,7 @@ final class DiviReplaceTextAbility implements Ability
*/
private function replace(DiviPage $page, TextReplacer $replacer): array
{
$attrWriter = $this->writer->composition($page)->attrWriter();
$cleaner = $this->writer->composition($page)->cleaner();
$count = 0;
$samples = [];
$blocks = [];
@@ -404,16 +404,8 @@ final class DiviReplaceTextAbility implements Ability
continue;
}
$module = $block->name();
$clean = static function (string $path, ?string $key, string $value) use ($attrWriter, $module): ?string {
$kind = $attrWriter->kindOf($module, $path, $key);
return $kind === 'text' || $kind === 'html'
? (string) $attrWriter->cleanValue($module, $path, $value, $key)
: null;
};
try {
$result = $replacer->apply($block->attrs(), $clean);
$result = $replacer->apply($block->attrs(), $cleaner->textReplacementFor($block->name()));
} catch (InvalidArgumentException $e) {
throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e);
}