fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors

Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().

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 14:26:18 +02:00
co-authored by Claude Opus 5.5
parent 4b72c2bb42
commit 8688a02402
13 changed files with 278 additions and 104 deletions
+47 -50
View File
@@ -25,7 +25,7 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
* writes itself is not client text: the builder version (checked in the constructor), block
* names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built
* from digits. Design-system names that match several entries are errors listing the ids
* (ruling S13a: colors/variables in Placeholders, presets here).
* (ruling S13a: colors/variables in Placeholders, presets in DesignSystem::modulePresetId()).
*/
final class Composer
{
@@ -390,16 +390,22 @@ final class Composer
return $attrs;
}
$id = is_string($preset) ? $this->presetId($name, $preset, $path) : null;
try {
$id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null;
} catch (InvalidArgumentException $e) {
// Ruling S13a: a name several presets share (DesignSystem lists their ids).
$this->errors[$path] = $e->getMessage();
return $attrs;
}
if ($id === null) {
if (! isset($this->errors[$path])) {
$names = $this->design->modulePresetNames($name);
$this->errors[$path] = sprintf(
'unbekanntes Preset; vorhanden für %s: %s',
$name,
$names === [] ? '(keine)' : implode(', ', $names)
);
}
$names = $this->design->modulePresetNames($name);
$this->errors[$path] = sprintf(
'unbekanntes Preset; vorhanden für %s: %s',
$name,
$names === [] ? '(keine)' : implode(', ', $names)
);
return $attrs;
}
@@ -422,42 +428,6 @@ final class Composer
return $attrs;
}
/**
* Module preset id by id, else by name (case-insensitive); a name several presets of the
* module share is an error listing their ids (ruling S13a). Null when unknown or ambiguous.
*/
private function presetId(string $name, string $preset, string $path): ?string
{
$wanted = trim($preset);
$key = mb_strtolower($wanted);
$ids = [];
foreach ($this->design->presets() as $entry) {
if ($entry['kind'] !== 'module' || $entry['module'] !== $name) {
continue;
}
if ($entry['id'] === $wanted) {
return $entry['id'];
}
if (mb_strtolower($entry['name']) === $key) {
$ids[] = $entry['id'];
}
}
if (count($ids) > 1) {
$this->errors[$path] = sprintf(
'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.',
$wanted,
implode(', ', $ids)
);
return null;
}
return $ids[0] ?? null;
}
/**
* Ruling S13: the short-form color check (ShortformWriter::color()), never text().
*
@@ -494,7 +464,8 @@ final class Composer
}
/**
* "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row.
* "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to exactly one row
* (exact integer arithmetic over the common denominator, ruling S34 – no float tolerance).
*
* @return list<string>
*/
@@ -502,7 +473,7 @@ final class Composer
{
$parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : [];
$widths = [];
$sum = 0.0;
$fractions = [];
foreach ($parts as $part) {
$valid = preg_match('/^(\d{1,2})_(\d{1,2})$/D', $part, $match) === 1
&& (int) $match[1] >= 1
@@ -523,10 +494,10 @@ final class Composer
return ['24_24'];
}
$sum += $numerator / $denominator;
$fractions[] = [$numerator, $denominator];
}
if ($widths === [] || abs($sum - 1.0) > 0.01) {
if ($widths === [] || ! self::sumsToOne($fractions)) {
$this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")';
return ['24_24'];
@@ -535,6 +506,32 @@ final class Composer
return $widths;
}
/**
* Whether the fractions add up to exactly 1: numerators scaled to the least common
* denominator (denominators are divisors of 24 or 5, 7, 9, 10, 11 – at most 27720).
*
* @param list<array{int, int}> $fractions [numerator, denominator]
*/
private static function sumsToOne(array $fractions): bool
{
$common = 1;
foreach ($fractions as [, $denominator]) {
$common = intdiv($common * $denominator, self::gcd($common, $denominator));
}
$sum = 0;
foreach ($fractions as [$numerator, $denominator]) {
$sum += $numerator * intdiv($common, $denominator);
}
return $sum === $common;
}
private static function gcd(int $a, int $b): int
{
return $b === 0 ? $a : self::gcd($b, $a % $b);
}
/**
* @param array<array-key, mixed> $spec
* @param list<string> $allowed