От боли к примитиву: как Spintax получил согласование с числом

Краткий инженерный трейс того, как примитив {plural <count>: form|…} прошёл путь от обсуждения в бэклоге до выпущенного кода в двух движках (PHP-плагин, TypeScript-бандл) с общей семантикой. Полезно, если когда-нибудь придётся аргументировать новый примитив в стабильном движке — есть референс для рабочего паттерна "решить сначала, построить один раз, документировать разрыв между намерением и реальностью".

Триггер

Согласование с числом месяцами сидело в бэклоге движка со статусом «отложено — решения зафиксированы, ждём триггер». Изначально перевести его из отложенных в активные должны были три сигнала от редактора:

  • Live-шаблон с %N% {form1|form2|form3} (баг случайного выбора, а не фича).
  • Вопрос редактора в духе «как сделать N + существительное правильно».
  • Две или больше встроенных обходных конструкции в разных шаблонах, начавших расходиться.

Ни один не сработал. Сработал четвёртый путь: анализ реальной стоимости на Generator — нашем мультитенантном контентном рантайме. Аудит перечислил обходные пути, которые понадобились бы для вывода чисел в рендеренной копи — и стоимость сложилась в числа, которые не работают.

Числа, запустившие триггер

  • Зависимость от инженера в цикле. Каждый новый счётчик (новая колонка данных казино, литерал в копи) заставлял бы менять ассемблер → деплоить воркер → править шаблон. Редакторы не могли автономно добавить фразу с числом. Это организационная связанность, а не косметическое трение.
  • Литералы недоступны. Фразы вроде «за 30 дней», «5 крипт» требуют согласования по значениям, которых нет в слое данных казино. Заготовленные флаги наличия не помогают.
  • Мультипликативный взрыв на реальных сущностях. Шесть считаемых сущностей (Languages, Cryptos, Providers, Bonuses, FreeSpins, Days, Hours) × 3 флага наличия × несколько источников счётчиков × макросы на каждое существительное в каждом пресете. Стоимость пошла из «дороже» в «непригодно к поддержке», как только перечислили на реальных данных.

Строгое чтение критериев триггера всё ещё говорило «ждать». Чтение по духу — доказательство, что разрыв реален и измерен, а не гипотетичен — говорило, что это и есть триггер. Отсрочка существовала, чтобы избежать работы на гипотезах; анализ был противоположностью гипотезы.

Фиксация до кода

Что прошло хорошо: каждое значимое решение было принято и записано в бэклог до того, как была написана хоть одна строка кода. Маркер ({plural %N%|forms}), модель локали (post meta шаблона + опциональное переопределение на уровне конструкции), таблицы правил V1 (RU — 3 формы, EN — 2, других нет), форма AST-узла, поверхность валидатора, контракт между движками — всё зафиксировано в docs/backlog.md серией чисто дизайнерских обсуждений.

Дисциплина, которую это даёт: когда начинается имплементация, на каждый «погоди, а что насчёт…» уже есть ответ. Имплементация — механика.

Проверка реальностью

Когда имплементация наконец началась, обзор репозитория Generator нашёл то, чего обсуждения в бэклоге не зафиксировали: TS-имплементация уже была выпущена. Два файла, spintax-plurals.ts и spintax-plurals.test.ts, с ~70 проходящими тестами, терпимым режимом для продакшен-рендера и полной интеграцией в resolveForSite(). Команда Generator построила это автономно, пока бэклог spintax-плагина копил абстрактный дизайн.

И синтаксис отличался:

РешениеСпек в бэклогеРабочий TS
Маркер{plural %N%|форма1|форма2|форма3} (только pipe){plural <count>: форма1|форма2|форма3} (двоеточие разделяет)
Слот форм«Слоты — полные spintax-выражения — вложенные синонимы валидны»Запрещает вложенные {}/[] — сначала вынести через #set
Пустой/неопределённый count«Последний слот (other/many) + предупреждение»«Пустая строка»
Конфигурация локалиPost meta + переопределение на уровне конструкции {plural:en %N%|…}Один lang-параметр на вызов рендера, без переопределения на уровне конструкции

Спек в бэклоге был абстрактным дизайном до кода. TS-реализация была работающим контрактом. Где они расходились, побеждал работающий контракт — и по веским причинам. Двоеточие — более чистый разделитель (снимает опасность {12|forms} после подстановки вспомогательной переменной). Запрет скобок в формах ловит реальный баг молчаливой порчи, который абстрактный спек не учёл. Контракт «пусто → пусто» соответствует общему для движка правилу «неизвестная переменная рендерится пустой» из ранних коммитов.

Постскриптум — плагин для WordPress 3.0.0, @spintax/core 0.3.0: половина строки про «вынести через #set» стала историей. Когда её писали, #set схлопывал своё значение один раз, так что вынос синонима в него действительно очищал слот. С тех пор #set вернулся к макро-подстановке — он перекатывается при каждой ссылке, — а поведение «раскатать один раз» переехало в новую директиву #def. Само ограничение не изменилось, переименовали только запасной выход. Актуальная форма — в гайде по плюралям. Это и есть маленькая иллюстрация урока ниже: строка была верной в момент публикации и стала неверной без единой правки.

Правило сверки

Когда абстрактный дизайн расходится с рабочим кодом, побеждает работающий код, если расхождение не вскрывает реальный баг. Бэклог был переписан под TS-контракт — каждое зафиксированное решение обновлено, каждое «почему это, а не то» переписано с обоснованием по факту реализации, счётчик тестов обновлён с «~27 фикстур» на реальные ~70.

Урок: спеки полезны только когда они актуальны. Замороженный спек, не совпадающий с выпущенным кодом, создаёт неопределённость, а не ясность. Либо держите их синхронизированными, либо перестаньте называть документ спеком.

PHP-порт

С TS как каноническим контрактом PHP-порт — механика. Зеркалить алгоритм: тот же сканер, учитывающий скобки, тот же строгий числовой разбор, та же проверка числа форм, то же правило локали. Зеркалить тесты: 74 PHPUnit-кейса (по одному на TS-тест), некоторые свёрнуты там, где PHP-идиомы естественно объединяют проверки. Зеркалить контракт рантайма: терпимый режим ловит ошибки поблочно и выводит конструкцию буквой с полноширинными скобками — те же кодпоинты (U+FF5B / U+FF5D), та же логика выживания в пайплайне.

Точка вставки в пайплайн WordPress-плагина (Renderer.php) — там же, где resolveForSite() в Generator: между вторым проходом условий и раскрытием перечислений. Сначала идёт подстановка %var% (слот числа становится целым-литералом), потом плюральный проход работает на стабильном тексте.

Источник локали зависит от хоста. Generator читает allVars.lang. WordPress-плагин читает новый post meta _spintax_locale и откатывается на локаль сайта WP (get_locale()). Сам Plurals-класс не знает — он нормализует то, что получает, и ищет правило.

Общий объём порта: ~280 строк PHP на три новых файла (Plurals.php, PluralArityError.php, PluralFormError.php) плюс точечные правки Renderer.php и Validator.php, плюс PHPUnit-файл на 74 кейса. PHPCS и весь 309-тестовый набор зелёные. Плагин ушёл с 1.4.0 на 1.5.0.

Что осталось за рамками

Не менее важно, что не вошло:

  • Другие славянские (польский, чешский, словацкий, словенский, болгарский) — другие структуры корзин. Каждый требует своего триггера для конкретного языка.
  • Арабский (6 форм), валлийский (6 форм), иврит (4 формы), латышский (3 формы), французский (0/1 = единственное число) — совсем другие правила.
  • Переопределение локали на уровне конструкции ({plural:en %N%: …}) — был в абстрактном спеке, но реального потребителя не возникло. Отложено до появления.
  • Склонение существительных по падежам (склонение по падежу, не только по числу) — словарная задача, не алгоритмическая.
  • Форматирование чисел (разделители NBSP, десятичный разделитель по локали) — соседняя фича, отдельный релиз.
  • Полное CLDR-покрытие ~200 локалей — бесконечно движущаяся цель. Добавлять по реальному запросу.

Дисциплина: каждое «было бы здорово, если бы» названо явно и отвергнуто с причиной. Файл бэклога документирует каждую отсрочку с явным критерием повторного триггера.

Уроки

  • Измеренная гипотеза — уже не гипотеза. Анализ реальной стоимости может быть валидным триггером, даже когда формальные критерии «увидел в проде» не сработали. Зафиксируйте анализ в записи о триггере, чтобы история отсрочек оставалась честной.
  • Зафиксируйте решения до кода, но сверьте после. Спек, написанный до реализации, избавляет от бесконечных пересмотров по ходу работы. Сверка после реализации не даёт спеку превратиться в фикцию.
  • Рабочий код побеждает абстрактный дизайн. Когда они расходятся, по умолчанию верьте коду. Обновляйте спек. Разберитесь, прежде чем переопределять — код мог узнать что-то, чего не увидел спек.
  • Общие данные правил, отдельные рантаймы. Два движка (PHP, TS) держат независимые пути выполнения, но делят одну таблицу плюральных правил. Будущие локали добавляют одну запись таблицы, не две.
  • Механические порты сжимаются, когда контракт точный. PHP-порт занял один заход, потому что на каждый поведенческий вопрос уже был TS-ответ для зеркаливания.

Что это даёт

Плюральный примитив — фундамент, а не фича. Он позволяет редакторам автономно создавать фразы число + существительное по всей платформе. Казино с разным количеством крипт, провайдеров, языков, бонусов теперь читаются осмысленно по-разному в сгенерированной копи. Литеральные числа в редакторском тексте («за 30 дней», «5 крипт») наконец рендерятся правильно. SEO выигрывает от настоящей дифференциации каталога; читатель — от конкретных чисел вместо «много».

За редакторским взглядом на использование — см. Согласование с числом: {plural <count>: form|…}. За болевой стороной — почему любой существующий spintax-движок ломал это — см. Русский spintax: почему ваши числа всегда выглядят криво.