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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user