rewrite 2026-09 and 2027 articles for reader-facing quality
Build and deploy / deploy (push) Successful in 18s

This commit is contained in:
2026-07-31 22:26:56 +03:00
parent 3bfc3f21c4
commit 440c8721dc
69 changed files with 4424 additions and 3533 deletions
+260 -137
View File
@@ -1,151 +1,274 @@
function escapeHtml(value) { return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;'); }
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); }
function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; }
function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
function bodyText(html) { return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
const REFERENCES = deepFreeze({
rfc2119: { title: 'RFC 2119: Key words for use in RFCs', url: 'https://www.rfc-editor.org/rfc/rfc2119', version: 'BCP 14, March 1997, DOI 10.17487/RFC2119' },
rfc8174: { title: 'RFC 8174: Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words', url: 'https://www.rfc-editor.org/rfc/rfc8174', version: 'BCP 14, May 2017, DOI 10.17487/RFC8174' },
nist160: { title: 'NIST SP 800-160 Vol. 1 Rev. 1: Engineering Trustworthy Secure Systems', url: 'https://doi.org/10.6028/NIST.SP.800-160v1r1', version: 'Revision 1, November 2022, DOI 10.6028/NIST.SP.800-160v1r1' },
});
function sources(entries) { return '<ul>' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — версия: ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>'; }).join('') + '</ul>'; }
const FIXED_LESSON_CASES = deepFreeze({
'keep-wrap-replace-v1': { id: 'keep-wrap-replace-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Fixed in-memory planning literal. No Bitrix installation, project, version, API call, user, field, file, test, release, database, network, browser, environment, clock, secret, telemetry or production system is read, created or changed.' },
'undated-future-scenario-v1': { id: 'undated-future-scenario-v1', planDate: '', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' },
'unnamed-contract-v1': { id: 'unnamed-contract-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: '', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' },
'unnamed-version-boundary-v1': { id: 'unnamed-version-boundary-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: '', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' },
'missing-migration-evidence-v1': { id: 'missing-migration-evidence-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'missing', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' },
'positive-conclusion-v1': { id: 'positive-conclusion-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'migration-succeeded', boundary: 'Negative fixed literal only.' },
});
export function createFixedLessonCase(id = 'keep-wrap-replace-v1') { const value = FIXED_LESSON_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; }
function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); }
export function assessFixedLessonPlan(input) {
if (!Object.values(FIXED_LESSON_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case');
if (input.planDate !== '2027-02' || input.sourceCutoff !== '2026-07-31') return stop('stop-undated-future-scenario-or-cutoff', 'february-2027-plan-and-july-2026-cutoff-are-required', 'name-2027-02-and-2026-07-31');
if (!input.legacyContract?.name || input.legacyContract.state !== 'named-synthetic') return stop('stop-unnamed-legacy-contract', 'legacy-contract-must-be-a-named-synthetic-placeholder', 'name-a-placeholder-without-claiming-a-real-contract');
if (!input.versionBoundary?.name || input.versionBoundary.state !== 'named-synthetic') return stop('stop-unnamed-version-boundary', 'version-boundary-must-be-a-named-synthetic-placeholder', 'name-a-placeholder-without-claiming-a-version');
if (JSON.stringify(input.route) !== JSON.stringify(['keep', 'wrap', 'replace'])) return stop('stop-invalid-change-tree', 'the-fixed-route-must-preserve-keep-wrap-replace-order', 'use-the-fixed-planning-tree');
if (input.migrationEvidence?.state !== 'not-collected' || input.migrationEvidence?.kind !== 'synthetic-input') return stop('stop-missing-or-claimed-migration-evidence', 'migration-evidence-is-not-collected-in-this-plan', 'hand-off-an-evidence-question-to-a-future-owner');
if (input.requestedConclusion !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-conclusion', 'a-future-plan-cannot-claim-migration-success-or-release', 'use-synthetic-plan-hand-off');
return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, legacyContract: deepFreeze(cloneFixed(input.legacyContract)), versionBoundary: deepFreeze(cloneFixed(input.versionBoundary)), route: deepFreeze(cloneFixed(input.route)), migrationEvidence: deepFreeze(cloneFixed(input.migrationEvidence)), productionEffect: 'not-attempted', nextAction: 'give-the-fixed-synthetic-contract-to-a-future-evidence-owner' });
}
export function runFixedLessonFixture() {
const expected = [['keep-wrap-replace-v1', 'synthetic-plan-hand-off'], ['undated-future-scenario-v1', 'stop-undated-future-scenario-or-cutoff'], ['unnamed-contract-v1', 'stop-unnamed-legacy-contract'], ['unnamed-version-boundary-v1', 'stop-unnamed-version-boundary'], ['missing-migration-evidence-v1', 'stop-missing-or-claimed-migration-evidence'], ['positive-conclusion-v1', 'stop-disallowed-positive-conclusion']];
const checks = expected.map(([id, expectedStatus]) => ({ id, expected: expectedStatus, actual: assessFixedLessonPlan(createFixedLessonCase(id)).status }));
const sample = createFixedLessonCase();
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.route), checks: deepFreeze(checks) });
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
}
function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); }
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const practice = revision({ slug: 'editorial-2027-02-practice-bitrix-lessons', title: 'Уроки Bitrix для legacy-разработки: сохранить, обернуть, заменить', categories: ['Bitrix', 'Legacy', 'Практика'], cover: '/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg', excerpt: 'План на февраль 2027: дерево решений для legacy-контракта без выдуманного проекта или вызова API.', readingMinutes: 22 }, [
p('Это план/сценарий на 2027-02 с source cutoff 2026-07-31, а не рассказ о сделанной миграции Bitrix. Первая проблема legacy-команды проста: знакомый участок кода объявляют «старым» и заменяют до того, как названо правило, ради которого он живёт. Цена — потерянное неявное соглашение, повторная работа и спор уже после изменения, когда вернуть контекст дороже, чем остановиться раньше.'),
p('Вторая проблема — язык уверенности. Фразы «в этой CMS так работает» и «после обновления будет то же самое» маскируют отсутствие версии, контракта и evidence. Цена вымысла выше косметической: читатель получает ложную память о проекте, тесте или релизе. Поэтому здесь нет существующего Bitrix-проекта, API-вызова, пользователя, поля, migration test или будущего результата. Допустим только <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>.'),
h2('Не переписывать ярлык вместо правила'),
p('Название платформы не заменяет карту поведения. Под «Bitrix legacy» в этом материале понимается только учебная рамка: код, границы и договорённости могут пережить автора. Она не сообщает, какие модули установлены, как устроен конкретный шаблон, какие методы существуют и как они ведут себя. Практическая польза начинается не с поисков универсального рецепта, а с вопроса: что именно обязан сохранить следующий шаг, если подробности контракта ещё не подтверждены?'),
p('Для такого вопроса полезно дерево из трёх глаголов. <code>keep</code> означает оставить участок без заявления, что он правильный навсегда. <code>wrap</code> означает поставить вокруг него собственную границу наблюдения или адаптации, не приписывая старому коду новый смысл. <code>replace</code> означает лишь будущую кандидатуру замены после отдельного разрешённого discovery. Порядок важен: замена не становится исходной точкой только потому, что старый код неприятен.'),
figure('/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg', 'Дерево планового решения: назвать synthetic legacy-контракт, проверить границу версии, затем сохранить, обернуть или передать кандидатуру замены; красные ветки останавливают недатированный сценарий и чрезмерный вывод.', 'Схема — редакционная модель будущего сценария на 2027-02. Она не описывает поведение конкретной установки Bitrix и не объявляет миграцию выполненной.'),
table('Дерево без предположения о реальной системе', ['Ветвь', 'Допустимое действие в плане', 'Сохраняемая неопределённость', 'Запрещённый вывод'], [['Сохранить', 'зафиксировать вопрос у named synthetic contract', 'правило пока не проверено', 'код уже одобрен'], ['Обернуть', 'описать будущий adapter boundary', 'внутренности legacy неизвестны', 'вызов API существует'], ['Заменить', 'передать кандидатуру будущему owner', 'версия и миграционный путь не подтверждены', 'релиз или тест состоялся'], ['Остановить', 'вернуть stop-status', 'evidence не собран', 'проблемы нет']]),
h2('Что значит сохранить'),
p('Сохранение часто ошибочно называют бездействием. В зрелой разработке это контролируемое решение не менять слой, пока у изменения нет адреса. Оно требует больше дисциплины, чем механическое переписывание: надо назвать область неизвестности, не превратить её в баг и оставить получателю честный next step. В сценарии область называется <code>unnamed-legacy-contract</code>. Это не имя файла, не сущность Bitrix и не скрытая ссылка на реальный договор; это literal, который не разрешает добавить правдоподобные детали.'),
p('Зрелость здесь не равна терпимости к долгу. Если участок действительно мешает, его нельзя оправдывать словом legacy. Но и давление на срок не превращает догадку в доказательство. Сохранить означает признать: на дату cutoff нет утверждённого описания входов, выходов, владельца и допустимого изменения. Такая запись не закрывает задачу сопровождения; она не даёт закрыть её ложным позитивным результатом.'),
h2('Обернуть — отделить свой код от чужой неизвестности'),
p('Обёртка имеет смысл только как новая, узкая граница ответственности. Она не должна симулировать точное знание внутреннего API. В будущем authorized scope может решить, что нужны преобразование формата, проверка ошибки или изоляция зависимого места. В текущем сценарии этого решения нет. Поэтому слово <code>wrap</code> фиксирует форму разговора: будущее изменение обязано назвать наблюдаемый контракт и версию, а не скрыть предположение в удобном helper.'),
p('У обёртки есть цена. Она добавляет поверхность поддержки, новую точку расхождения и соблазн объявить старую часть безопасной лишь потому, что рядом появился современный интерфейс. Если boundary не назван, такая прослойка увеличивает долг. Именно поэтому fixture закрывает <code>unnamed-version-boundary-v1</code>: неизвестная граница версии не может быть украшена универсальной адаптацией. Fail closed здесь полезнее умной догадки.'),
h2('Заменить — не значит уже мигрировать'),
p('Замена начинается с перечисления того, чего мы не знаем. Нет migration evidence, значит нет основания писать «эквивалентно», «проверено» или «работает после переноса». Нет подтверждённой версии, значит нельзя обещать совместимость. Нет конкретного API-вызова, значит нельзя подменять модель псевдокодом, который выглядит как инструкция для реального проекта. Практический материал обязан быть исполнимым, но его исполнимость может ограничиваться безопасной проверкой литерала.'),
code("import { createFixedLessonCase, assessFixedLessonPlan } from './upgrade-2027-02.mjs';\n\nconst preservedQuestion = createFixedLessonCase('keep-wrap-replace-v1');\nconst handOff = assessFixedLessonPlan(preservedQuestion);\nconsole.log(JSON.stringify({ route: handOff.route, status: handOff.status, effect: handOff.productionEffect }));\n// {\"route\":[\"keep\",\"wrap\",\"replace\"],\"status\":\"synthetic-plan-hand-off\",\"effect\":\"not-attempted\"}"),
p('Пример буквально запускается только с exports этого модуля. Factory создаёт JSON clone fixed in-memory literal и рекурсивно применяет deep freeze; caller не получает общую изменяемую ссылку. Evaluator сверяет вход с известными named literals и закрывает произвольный object. В нём нет файлов, сети, browser, environment, часов, secrets, telemetry и внешних инструментов. Он не выполняет Bitrix-вызов и не проводит migration test; его output — только безопасная передача вопроса.'),
h2('Цена каждого преждевременного шага'),
p('У дерева есть не только техническая, но и экономическая логика. Ранняя замена переносит цену неизвестности в review, поддержку и откат: новый слой приходится объяснять одновременно с тем, что он пытается изменить. Раннее оборачивание обычно дешевле замены, но тоже не бесплатно — появляются преобразование, новая диагностика и отдельная ответственность за ошибки. Сохранение дешевле прямо сейчас, но становится дорогим, если команда не оставила понятный вопрос. Поэтому выбор нельзя сводить к «ничего не делать» против «сделать современно». Он сравнивает разные формы обязательств.'),
p('В учебной модели нет чисел, потому что числа без контекста были бы ещё одним вымыслом. Нет оценки часов, количества экранов, размера базы, числа интеграций или доли пользователей. Можно зафиксировать только направление риска: чем больше неподтверждённых предпосылок помещено в изменение, тем труднее отделить дефект нового кода от старого правила. Эта формулировка не предсказывает результат. Она объясняет, почему будущему owner передают список неизвестных, а не уверенное решение.'),
h2('Как не превратить обёртку в новый монолит'),
p('Будущая обёртка должна иметь маленький контракт и явное место удаления. Иначе она быстро превращается в комнату, куда складывают исключения: входы исправляются наугад, ошибки переименовываются, а вызывающий код перестаёт видеть исходную границу. В P108 запрещено придумывать такую реализацию, но разрешено назвать критерий для будущей проверки: у прослойки должен быть свой наблюдаемый input, свой output и основание существования. Если хотя бы один пункт нельзя назвать, решение остаётся на ветке keep.'),
p('Важно не путать изоляцию с сокрытием. Изоляция оставляет возможность спросить, какой контракт защищается и при каких версиях. Сокрытие делает старое поведение невидимым и заставляет следующий слой компенсировать симптомы. Первый вариант облегчает проверку, второй просто меняет место долга. Поэтому дерево не предлагает «обернуть всё» как безопасный default. Оно требует пройти через named placeholders, чтобы даже отсутствие знания было видно в интерфейсе решения.'),
h2('Порядок действий для будущего владельца'),
ol(['Сохранить редакционные метки <code>planDate: 2027-02</code> и <code>sourceCutoff: 2026-07-31</code>; без них сценарий закрывается.', 'Назвать только synthetic placeholder legacy-контракта, не превращая его в метод, проект, поле или пользовательский кейс.', 'Отдельно назвать synthetic version boundary; неизвестная версия — причина остановки, а не повод выбрать «наиболее вероятную».', 'Пройти дерево <code>keep → wrap → replace</code> как порядок рассмотрения, не как план работ или очередь.', 'Оставить migration evidence в состоянии <code>not-collected</code>; не заменять отсутствие test фиктивным положительным словом.', 'Передать вопрос будущему evidence owner, который отдельно решит, нужен ли authorized discovery и какие факты допустимы.']),
h2('Где дерево заканчивается'),
p('Это дерево не выбирает архитектуру, не измеряет производительность, не оценивает безопасность и не знает условий эксплуатации. Оно не говорит, что legacy-код надо оставить, что adapter нужен или что замена выгодна. Также оно не покрывает реальную совместимость версий Bitrix: у текста нет подтверждённой версии и нет права превращать архивную документацию в текущий факт. Модель намеренно меньше проекта, чтобы не создать фиктивный проект в редакционной статье.'),
p('Следующий шаг возможен только вне этого draft: будущий владелец может открыть отдельный scope, получить разрешение на discovery и зафиксировать provenance реальных материалов. До этого момента честный итог — hand-off. Февраль 2027 ещё не наступил относительно редакторской даты; обещать результат миграции означало бы выдать план за историю. Legacy не оправдывает бездействие, но требует, чтобы действие начиналось с названной границы, а не с красивого пересказа неизвестности.')
], [{ key: 'rfc2119', use: 'Использована только дисциплина явной модальности слов MUST/SHOULD в техническом контракте.', boundary: 'Не описывает Bitrix, migration или реальный API.' }, { key: 'rfc8174', use: 'Использовано уточнение, что модальные слова имеют заданный контекст.', boundary: 'Не подтверждает версию, тест или будущий результат.' }, { key: 'nist160', use: 'Использована общая инженерная граница между системой, контрактом и evidence.', boundary: 'Не подтверждает существование проекта или его поведения.' }]);
const REFERENCES = Object.freeze({
cuser: {
title: 'Главный модуль: CUser — документация 1С-Битрикс',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cuser/index.php',
version: 'документация API, проверена 31 July 2026; класс доступен с версии 3.0.6',
},
loader: {
title: 'CModule::IncludeModule — документация 1С-Битрикс',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cmodule/includemodule.php',
version: 'документация API, проверена 31 July 2026; CModule с версии 3.0.1',
},
filter: {
title: 'PHP Manual: filter_var',
url: 'https://www.php.net/manual/en/function.filter-var.php',
version: 'PHP Manual, актуальная страница, проверена 31 July 2026',
},
semver: {
title: 'Semantic Versioning 2.0.0',
url: 'https://semver.org/spec/v2.0.0.html',
version: 'Version 2.0.0, 2013',
},
});
const mechanism = revision({ slug: 'editorial-2027-02-mechanism-bitrix-lessons', title: 'Уроки Bitrix для legacy-разработки: граница версии за вызовом', categories: ['Bitrix', 'Legacy', 'Архитектура'], cover: '/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg', excerpt: 'План на февраль 2027: как не принять имя API за инвариант неизвестной версии.', readingMinutes: 21 }, [
p('План/сценарий на 2027-02 с source cutoff 2026-07-31 разбирает дорогую ошибку механизма: имя вызова принимают за гарантию поведения. В legacy-разработке это быстро превращает догадку в интерфейс нового кода. Цена — адаптер вокруг ложного инварианта, спор о «совместимости» и новый долг, который уже труднее увидеть, потому что он написан современным синтаксисом.'),
p('Следующая ловушка — приписать этот механизм реальному Bitrix. У данного текста нет установки, проекта, версии, API-вызова, релиза, поля, пользователя или теста. Цена такой конкретизации — вымышленный факт, который могут использовать как инструкцию. Поэтому все имена ниже — synthetic placeholders; положительного вывода о миграции нет. Разрешён только <code>synthetic-plan-hand-off</code> с неизменным <code>productionEffect: not-attempted</code>.'),
h2('Вызов виден, инвариант спрятан'),
p('Вызов — это форма обращения к границе. Инвариант — свойство, которое следующий код рассчитывает сохранить: порядок, отсутствие, преобразование, ошибка, идемпотентность или другой явно названный эффект. Эти сущности нельзя склеивать. Один и тот же внешний вид строки кода может на разных границах означать разные обязательства, а похожие вызовы могут не иметь общего контракта. Мы не можем выяснить это по названию и не должны подменять проверку привычкой.'),
p('Чтобы не создавать фальшивую точность, модель использует <code>unnamed-version-boundary</code>. Это не «неизвестная версия Bitrix», а явная synthetic метка отсутствующего знания. Рядом стоит <code>unnamed-legacy-contract</code>, также не связанный с API. Пара нужна, чтобы удержать вопрос в двух плоскостях: что обещает контракт и при каком version boundary это вообще можно обсуждать. Если отсутствует любая из них, evaluator останавливает сценарий.'),
figure('/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg', 'Матрица плановой границы версии: имя вызова, named synthetic contract, named synthetic version boundary и допустимый вывод; красные ячейки показывают stop при отсутствующем контракте или версии.', 'Матрица не сопоставляет настоящие версии Bitrix и не документирует API. Она показывает, почему форма вызова не доказывает инвариант.'),
table('Четыре уровня утверждения', ['Уровень', 'Что можно назвать в этом draft', 'Что требуется для усиления', 'Что запрещено'], [['Лексема', 'synthetic имя boundary', 'отдельный источник и scope', 'реальный метод'], ['Контракт', 'placeholder без поведения', 'проверяемое описание input/output', 'обещание совместимости'], ['Версия', 'placeholder без номера', 'подтверждённый provenance', 'название релиза'], ['Вывод', 'hand-off вопроса', 'реальное разрешённое evidence', 'migration succeeded']]),
h2('Почему версия — не число в комментарии'),
p('Граница версии — не декоративное поле. Она определяет, к чему относится утверждение: к документации, развертыванию, интеграции или предположению автора. Пока нет provenance, номер версии опаснее пустоты: он создаёт ощущение проверяемости и направляет людей к несуществующему релизу. В данном будущем сценарии даже правдоподобный номер был бы выдумкой. Поэтому model требует имя placeholder, но запрещает превращать его в факт.'),
p('У этой строгости есть практическая цена сейчас: документ получается менее эффектным, а следующий инженер не получает готового рецепта. Это приемлемая цена по сравнению с обратной стоимостью ложной совместимости. Legacy не получает иммунитет от ответственности; наоборот, зрелая система требует различать «не знаем» и «можем безопасно предположить». Здесь безопасного предположения нет, поэтому evaluator fail closed.'),
h2('Инвариант нужно формулировать без магии API'),
p('Полезный инвариант начинается не с объекта библиотеки, а с наблюдаемого условия: что вход считается допустимым, что выход считается тем же, где ошибка остаётся ошибкой и кто вправе трактовать результат. Но и это условие нельзя сочинить за чужую систему. В P108 условие нарочно не заполнено; named literal показывает место, в которое будущий owner может положить отдельный, подтверждённый контракт. До того никто не получает право назвать конкретное поведение «стандартным для Bitrix».'),
p('Такой пробел не надо маскировать псевдокодом. Особенно вредны примеры, которые выглядят как настоящий API-вызов: они переживают оговорку и начинают жить в копипасте. Исполнимый пример ниже проверяет только дисциплину модели. Он показывает, что отсутствие version boundary останавливает передачу, а не подталкивает систему к запасной ветке. Это меньшая демонстрация, но она честна относительно source cutoff.'),
h2('Исполнимая проверка границы'),
code("import { createFixedLessonCase, assessFixedLessonPlan } from './upgrade-2027-02.mjs';\n\nconst boundaryIsMissing = createFixedLessonCase('unnamed-version-boundary-v1');\nconst gate = assessFixedLessonPlan(boundaryIsMissing);\nconsole.log([gate.status, gate.productionEffect, gate.nextAction].join(' | '));\n// stop-unnamed-version-boundary | not-attempted | name-a-placeholder-without-claiming-a-version"),
p('Этот runnable fragment обращается только к fixed in-memory literal. В factory сначала происходит JSON clone, затем deep freeze всех вложенных значений, поэтому нельзя тайно дописать версию после выдачи case. Оценка принимает только known cases и возвращает stop для неназванной границы. Нет request, файлового fixture, process environment, времени, browser, API, базы или telemetry. Вывод не описывает реальный failure; он лишь охраняет границу редакционной модели.'),
h2('Как псевдоинвариант попадает в код'),
p('Обычно ложный инвариант начинается с мелочи: в обсуждении кто-то говорит «этот вызов всегда возвращает нужную форму», а через неделю фраза становится условием в adapter. Потом вокруг условия появляются обработка ошибки, кеш или fallback, и уже кажется, будто договор подтверждён самим количеством кода. На деле объём не заменяет provenance. Чем удобнее ложное правило для реализации, тем настойчивее надо требовать источник, version boundary и альтернативу, при которой правило не действует.'),
p('У этого механизма есть коварная особенность: он может пережить смену людей. Новый инженер видит хорошо названную функцию и считает, что имя отражает установленный факт. Поэтому хороший контракт не прячет неизвестность за неймингом. В плановом literal слово <code>unnamed</code> намеренно неприятно: оно не позволяет случайно принять placeholder за доменное понятие. Когда future owner получит реальные основания, он создаст новый артефакт с отдельной датой, а не переименует задним числом эту метку.'),
h2('Совместимость — составное утверждение'),
p('Говорить о совместимости можно только после разложения вопроса. Совпадает ли формат входа? Сохраняется ли смысл отсутствующего значения? Одинаково ли обрабатывается ошибка? Есть ли различие в порядке побочных действий? В настоящем проекте эти вопросы требуют конкретных материалов. В P108 их нельзя заполнить, но можно не потерять структуру. Матрица удерживает составные части рядом и не позволяет одной знакомой лексеме выдать себя за весь набор гарантий.'),
p('Именно поэтому table не содержит колонку «поддерживается». Поддержка — вывод о реальном сочетании версии, конфигурации и применения, которого здесь нет. Проставить зелёную галочку означало бы совершить тот же логический скачок, от которого материал защищает. Зелёный в схеме означает только допустимость hand-off формы вопроса. Он не означает работоспособность, vendor commitment или возможность переноса. Это различие нужно проговорить явно, потому что цвет и таблица слишком легко читаются как verdict.'),
h2('Кому полезен stop-status'),
p('Stop нужен не только ревьюеру. Автору изменения он экономит время на документ, который иначе пришлось бы переписывать после первого уточнения. Получателю он даёт право не соглашаться с исходной постановкой: можно вернуть карточку, если граница названа слишком широко, а evidence не позволяет выбрать безопасный следующий шаг. Руководителю stop показывает, что работа не исчезла, но ещё не получила право называться реализацией. Такая прозрачность лучше оптимистичного статуса, которому никто не может показать основание.'),
p('Fail closed также предотвращает тихую эскалацию scope. Когда evaluator принимает только byte-identical fixed cases, нельзя передать произвольный object со скрытым API-именем, реальной версией или положительным claim. Ограничение намеренно грубое: оно не моделирует домен, а охраняет редакционную границу. В будущем более богатая модель возможна, но она должна появиться вместе с доказуемым источником, владельцем и правилами её использования. До этого простая остановка честнее универсальной функции.'),
h2('Как разбирать изменение за API-вызовом'),
ol(['Отделить текст вызова от утверждения о его эффекте; в этом draft не считать ни одно имя вызова реальным.', 'Записать legacy contract как synthetic placeholder, пока отдельный владелец не подтвердит вход, выход и ошибку.', 'Записать version boundary отдельным placeholder; не вставлять номер, релиз или ссылку на непроверяемый архив.', 'Проверить, что change tree сохраняет порядок <code>keep → wrap → replace</code>, а не перескакивает к миграции.', 'Отклонить case с отсутствующим contract, version boundary или migration evidence, даже если остальная история выглядит убедительно.', 'Передать только вопрос и его stop-conditions будущему evidence owner; не создавать test, project task или production action.']),
h2('Матрица не заменяет исследование'),
p('Матрица полезна тем, что делает видимыми пропуски, но она не добывает данные. Она не отвечает, существует ли версия, повторяется ли ошибка, вызовет ли переход регрессию или какой слой принадлежит конкретной команде. Она также не устанавливает ценность обёртки: одна и та же прослойка может быть защитой или лишним посредником, если контракт не доказан. Любая попытка назначить ответ по одной таблице была бы тем самым неверным инвариантом.'),
p('Следующий шаг — отдельный authorized discovery после будущей даты сценария: получатель сам решает, нужны ли документы, исходный код, изолированная проверка или иной evidence. Если такого scope нет, stop — корректный конечный результат данного материала. Это не оправдание бездействия; это запрет на действие под видом знания. Февраль 2027 не наступил, и редакторская модель не может рассказывать о его совместимости, релизе или выполненной миграции.')
], [{ key: 'rfc2119', use: 'Использована только идея точно ограниченной нормативной лексики.', boundary: 'Не задаёт поведение API и не доказывает инвариант Bitrix.' }, { key: 'rfc8174', use: 'Использована граница контекста, в котором модальные слова приобретают смысл.', boundary: 'Не подтверждает номер версии или релиз.' }, { key: 'nist160', use: 'Использовано общее различение инженерных границ и свидетельств.', boundary: 'Не является источником migration test или проекта.' }]);
function sources(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const reference = REFERENCES[key];
return '<li><a href="' + reference.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(reference.title) + '</a> — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
const field = revision({ slug: 'editorial-2027-02-field-bitrix-lessons', title: 'Уроки Bitrix для legacy-разработки: evidence hand-off без отчёта', categories: ['Bitrix', 'Legacy', 'Надёжность'], cover: '/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg', excerpt: 'План на февраль 2027: передать вопрос о миграционном evidence, не выдумывая тест, релиз или успех.', readingMinutes: 20 }, [
p('Редакторская дата — 31.07.2026; плановый номер обозначен 2027-02, то есть февраль 2027, а источники ограничены cutoff 2026-07-31. В field-работе самая дорогая ошибка начинается с фразы «миграция подтверждена», когда у передачи нет ни provenance, ни материалов, ни даже утверждённого теста. Цена — поиск несуществующего evidence, неверные решения и операционная память, которую придётся разбирать уже после того, как на неё оперлись.'),
p('Особенно опасно делать такую фразу правдоподобной деталями про Bitrix: назвать проект, пользователя, поле, API-вызов, выпуск или migration test. Ничего этого здесь не существует и не предполагается. Февраль 2027 ещё впереди; положительный outcome запрещён. Модель заканчивается только <code>synthetic-plan-hand-off</code> и всегда сообщает <code>productionEffect: not-attempted</code>, а не результат работы в production.'),
h2('Evidence начинается с происхождения, не с вывода'),
p('Evidence — это не красивое существительное для уверенной фразы. Чтобы материал мог поддержать решение, нужны источник, дата, условия получения, граница интерпретации и владелец. В synthetic сценарии этих вещей нет, поэтому состояние названо <code>not-collected</code>. Это не плохое значение и не намёк на скрытый файл: оно означает, что статья передаёт вопрос, а не артефакт. Любое усиление до «наблюдалось» сделало бы модель ложной.'),
p('Legacy требует такой же честности, как новая система. Старый слой может хранить много неявных правил, но его возраст не даёт разрешения дорисовывать результат. Если будущее изменение действительно понадобится, evidence должен появиться в отдельном authorized scope с собственными границами и датой. Нельзя ретроспективно переписать этот draft после появления новых данных: тогда получится другой артефакт, а не уточнение исходного hand-off.'),
figure('/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg', 'Петля плановой передачи evidence: fixed synthetic question ведёт к будущему владельцу, который либо открывает отдельный scope, либо возвращает отсутствие evidence; красная ветка блокирует claim о готовой миграции.', 'Схема показывает только маршрутизацию неопределённости. Она не утверждает наличие миграционного теста, результата, релиза либо доказательств для Bitrix.'),
table('Что передаётся и чего в пакете нет', ['Элемент', 'Состояние P108', 'Кому принадлежит решение', 'Недопустимая подмена'], [['Вопрос', 'named synthetic literal', 'future evidence owner', 'готовый incident'], ['Контракт', 'placeholder', 'отдельный scope', 'реальное поле или API'], ['Migration evidence', 'not-collected', 'авторизованный процесс', 'пройденный test'], ['Вывод', 'synthetic-plan-hand-off', 'будущая оценка', 'success/release']]),
h2('Пустота evidence должна быть видна'),
p('Иногда команда скрывает отсутствие материалов за термином «полевая проверка». Это создаёт опасный мост между планом и утверждением: читатель видит familiar labels и предполагает, что кто-то запускал систему. В P108 поле намеренно пустое в фактическом смысле. Есть только fixed literal, который показывает форму будущей передачи. У него нет имени окружения, ссылки на задачу, записи лога, снимка экрана, тестового пользователя или вызова внешнего сервиса.'),
p('Такая пустота не отменяет стоимость проблемы. Напротив, она предотвращает дорогой вторичный дефект: решение принимают на основании artefact, который невозможно найти. Прагматичный hand-off говорит получателю меньше, но говорит надёжно: что неизвестно, какая граница обязана быть названа и какой результат сейчас запрещён. Это лучше, чем «отчёт» с реалистичной терминологией и несуществующей ответственностью.'),
h2('Negative case важнее удобного успеха'),
p('Fixture содержит <code>missing-migration-evidence-v1</code>. Он не пытается вылечить отсутствие evidence резервным значением и не делает вид, что отсутствие равно чистому результату. Вместо этого возвращается <code>stop-missing-or-claimed-migration-evidence</code>. Такая ветка проверяет редакционную честность: если author позже добавит слово collected или positive conclusion, модуль не превратит это в hand-off по умолчанию.'),
p('Отрицательная ветка защищает и от другого соблазна — выставить migration succeeded как желаемый статус. Желаемое не равно наблюдаемому, а план не равен релизу. В будущем у владельца могут быть основания исследовать проблему; в настоящей модели их нет. Fail closed не решает, нужна ли миграция. Он не даёт использовать этот текст как свидетельство того, что миграция была начата, проведена или завершена.'),
h2('Безопасный hand-off в памяти'),
code("import { createFixedLessonCase, assessFixedLessonPlan } from './upgrade-2027-02.mjs';\n\nconst evidenceWasNotProvided = createFixedLessonCase('missing-migration-evidence-v1');\nconst refusal = assessFixedLessonPlan(evidenceWasNotProvided);\nconsole.log({ refusal: refusal.status, action: refusal.nextAction, production: refusal.productionEffect });\n// { refusal: 'stop-missing-or-claimed-migration-evidence', action: 'hand-off-an-evidence-question-to-a-future-owner', production: 'not-attempted' }"),
p('Этот code example runnable, но его предмет узок: known literal клонируется через JSON и замораживается deep freeze до оценки. Никакой fixture не читается с диска; никакой веб-запрос, тестовый runner, Bitrix API, браузер, переменная окружения, часы или секрет не используются. Публичный export позволяет проверить stop-status, но не создаёт migration evidence. В частности, он не называет API, проект, пользователя или реальное поле, потому что этого knowledge у сценария нет.'),
h2('Почему test name не является evidence'),
p('Название теста может быть полезным указателем, но само по себе не говорит, что именно запускалось, на чём, с какими входами и кто интерпретировал результат. В будущей миграции это различие особенно важно: одинаковая метка может скрывать разный набор правил, а зелёный сигнал — быть следствием неполного охвата. Поэтому P108 не создаёт даже synthetic migration test. Создать его было бы проще для повествования, но читатель неизбежно начал бы спрашивать о фикстурах, окружении и результате, которых не существует.'),
p('Вместо test name hand-off несёт более скромную, но проверяемую информацию: evidence не collected, его сила не повышалась, а утверждение об успехе запрещено. Эта структура позволяет будущему owner начать с правильного вопроса — нужен ли вообще evidence для выбранного решения и какому уровню доверия он должен соответствовать. Она не подсказывает ответ заранее и не создаёт обязательства обязательно запускать проверку. Иногда корректным выходом discovery будет отказ от изменения, а не доказательство миграции.'),
h2('Провенанс нельзя дописать позже'),
p('После source cutoff могут появиться документы, снимки, реальные тесты и результаты. Они не делают исходный draft ретроспективным отчётом. У новых материалов будет своя дата, доступ, условия и автор; смешать их со старым сценарием значит стереть разницу между планированием и наблюдением. Такая подмена кажется безобидной, пока не возникает вопрос, какие именно факты были известны до решения. Тогда аккуратно сохраненная пустота становится важнее подробной, но переписанной истории.'),
p('Провенанс важен и когда outcome отрицательный. Будущий owner может обнаружить, что доказательства недоступны, контракт слишком широкий или сама постановка ошибочна. Это не делает hand-off неудачным: его задача не добиться красивого направления, а сохранить возможность честно выбрать направление позже. Поэтому <code>productionEffect: not-attempted</code> — не декоративный флаг. Он не разрешает читать его как «ничего не произошло в реальном мире»; он говорит лишь, что данный модуль не пытался воздействовать на production.'),
h2('Синтетический пакет не становится очередью'),
p('Даже хорошо оформленный hand-off способен незаметно стать обязательством, если в нём появляется исполнитель, срок или обещанный результат. В этом материале их нет. Future evidence owner — роль, а не человек; next action — передача вопроса, а не команда получить данные; маршрут на схеме — объяснение границы, а не workflow существующей организации. Такое ограничение сохраняет автономию получателя и не выдаёт редакторский draft за управленческое решение.'),
p('Если получателю нужно действие, он должен завести его в новом авторизованном контексте с собственными данными и рисками. P108 не может сделать это вместо него, как не может создать совместимость одной строкой. Сила пакета в другом: он не оставляет лазейки для утверждения «мы уже проверили». Когда отсутствует migration evidence, честная остановка — полезный результат подготовки, а не повод сочинить positive outcome, чтобы текст выглядел законченным.'),
h2('Порядок передачи вместо псевдоотчёта'),
ol(['Сохранить в hand-off дату будущего плана и source cutoff, чтобы не спутать его с фактической работой.', 'Оставить legacy contract и version boundary synthetic placeholders, не дополняя их знакомыми, но неподтверждёнными именами.', 'Зафиксировать migration evidence только как <code>not-collected/synthetic-input</code>; отсутствие не трактовать как успех.', 'Запустить fixture против negative literal и убедиться, что он выдаёт stop, а не fallback.', 'Не создавать ticket, очередь, release note, test report или production change из результата этой функции.', 'Передать будущему owner вопрос, ограничения и next action; разрешение на получение реальных материалов остаётся вне P108.']),
export function chooseLegacyBoundary(input) {
const callers = Number(input?.callers ?? 0);
const hasTests = Boolean(input?.characterizationTests);
const hasSideEffects = Boolean(input?.unknownSideEffects);
const hasCanonicalContract = Boolean(input?.canonicalContract);
if (hasSideEffects || !hasTests) return { action: 'keep', reason: 'сначала сохранить наблюдаемое поведение и добавить тесты' };
if (callers > 1 && !hasCanonicalContract) return { action: 'wrap', reason: 'несколько callers требуют одной адаптационной границы' };
if (hasCanonicalContract) return { action: 'replace', reason: 'новый контракт проверен тестами и отделён от legacy API' };
return { action: 'wrap', reason: 'изменение изолируем до появления полного контракта' };
}
export function inspectBitrixSurface(input) {
const methods = new Set(input?.methods ?? []);
const moduleLoaded = Boolean(input?.moduleLoaded);
const version = String(input?.version ?? 'unknown');
if (!moduleLoaded) return { status: 'module-not-loaded', action: 'проверить подключение iblock или main до вызова' };
if (methods.has('CUser::GetByID') && methods.has('CUser::Update')) return { status: 'legacy-surface-present', action: 'использовать адаптер с явной проверкой версии', version };
if (methods.has('Bitrix\\Main\\UserTable')) return { status: 'd7-surface-present', action: 'проверить mapping полей перед заменой', version };
return { status: 'unknown-surface', action: 'остановить миграцию и получить контракт установленной версии', version };
}
export function migrateUserFields(record) {
const source = { ...record };
const phone = String(source.PERSONAL_PHONE ?? '').trim();
const email = String(source.EMAIL ?? '').trim().toLowerCase();
const result = { ...source, phone, email };
delete result.PERSONAL_PHONE;
delete result.EMAIL;
return { legacy: source, canonical: result, reversible: true };
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const practice = revision({
slug: 'editorial-2027-02-practice-bitrix-lessons',
title: 'Bitrix legacy: когда сохранить код, когда обернуть, когда заменить',
categories: ['Bitrix', 'Инженерные практики'],
cover: '/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg',
excerpt: 'Практическое дерево решения для старого API: сохранить поведение, поставить адаптер или перейти на новый контракт.',
readingMinutes: 15,
}, [
p('Проблема legacy-кода в Bitrix редко состоит в возрасте файла. Старый вызов может держать неявные значения, порядок хуков, формат ошибок и поля, которые читает соседний модуль. Если заменить его только потому, что новый API выглядит аккуратнее, цена проявится позже: потеряется поведение, а исправлять его придётся уже по косвенным симптомам. Поэтому первый вопрос — не «как переписать», а «какой контракт нельзя сломать».'),
p('Решение удобно разделить на три действия: сохранить, обернуть или заменить. Сохранить — значит оставить вызов и зафиксировать его наблюдаемое поведение. Обернуть — поставить адаптер между legacy API и остальным кодом, чтобы callers перестали знать детали. Заменить — удалить старую границу только после того, как новый контракт описан тестами. Это не догма: выбор зависит от числа callers, побочных эффектов и качества проверки.'),
h2('Сначала отделяем возраст от риска'),
p('Класс <code>CUser</code> относится к старому API Bitrix, но само имя класса не говорит, что его можно безопасно удалить. Документация перечисляет поля и методы, а также указывает аналог в D7. Для проекта этого мало: нужно увидеть, какие поля реально передаются, какие значения возвращаются и что вызывается после сохранения. Пока это не известно, сохранение или узкий wrapper дешевле полной миграции.'),
p('Риск растёт, если один вызов смешивает несколько задач. Например, функция может одновременно нормализовать email, создавать пользователя, запускать событие и возвращать ID. Переписать её на новый класс без раздельных тестов значит поменять четыре контракта за один commit. Адаптер позволяет сначала выделить форму данных и ошибку, а уже потом менять внутреннюю реализацию.'),
figure('/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg', 'Дерево выбора для Bitrix legacy: проверить callers и контракт, затем выбрать сохранение, адаптер или замену.', 'Схема помогает выбрать границу изменения. Красная ветка означает, что сначала нужно получить недостающий контракт, а не переписывать вызов.'),
table('Три действия для legacy-вызова', ['Действие', 'Когда подходит', 'Цена', 'Критерий перехода'], [
['Сохранить', 'побочные эффекты не описаны, callers мало', 'остаётся старый долг', 'характеризующие тесты и список полей'],
['Обернуть', 'callers несколько, контракт можно выделить', 'появляется адаптер', 'внешний код видит canonical input/output'],
['Заменить', 'новый контракт и тесты готовы', 'нужен полный regression', 'старый путь больше не нужен'],
['Остановиться', 'нет версии, полей или воспроизводимого результата', 'изменение откладывается', 'собраны факты о границе'],
]),
h2('Учебная функция выбора границы'),
p('Функция ниже не изображает Bitrix runtime. Она показывает рабочую механику решения: входом являются количество callers, наличие characterization tests, неизвестные побочные эффекты и canonical contract. Возвращается одно действие и причина. Эти поля можно заполнить из code search, тестов и документации, а не из ощущения «код старый».'),
code(`import { chooseLegacyBoundary } from './upgrade-2027-02.mjs';
const cases = [
{ callers: 1, characterizationTests: false, unknownSideEffects: true, canonicalContract: false },
{ callers: 4, characterizationTests: true, unknownSideEffects: false, canonicalContract: false },
{ callers: 4, characterizationTests: true, unknownSideEffects: false, canonicalContract: true },
];
for (const item of cases) console.log(chooseLegacyBoundary(item));
// keep -> сохранить наблюдаемое поведение и добавить тесты
// wrap -> несколько callers требуют одной адаптационной границы
// replace -> новый контракт проверен тестами и отделён от legacy API`),
p('Первый результат намеренно не предлагает «сразу новый класс»: неизвестные эффекты сильнее желания обновиться. Второй показывает пользу wrapper, когда callers несколько. Третий разрешает замену только при наличии canonical contract. В рабочем репозитории эту функцию заменит ADR или короткая карточка изменения, а значения подтвердят тесты и просмотр вызовов.'),
h2('Что должен скрывать адаптер'),
p('Адаптер не должен превращаться в копию всего legacy API. Он принимает только нужные поля, проверяет обязательные значения и переводит ошибку в понятный тип. Например, внешняя функция может принимать <code>{ email, phone }</code>, а внутри временно собирать массив полей для <code>CUser::Update</code>. Так callers перестают зависеть от названий <code>PERSONAL_PHONE</code> и от способа загрузки модуля.'),
p('Нужно заранее решить, кто владеет преобразованием. Если один caller исправляет телефон, второй передаёт его как есть, а третий пишет пустую строку, wrapper не создал контракт — он спрятал разнобой. Входная нормализация должна быть в одном месте, а правила обратного преобразования — рядом с ней. В таблице изменений укажите поле, источник, допустимую пустоту и способ проверить результат.'),
h2('Действия по порядку'),
ol([
'Найти все callers legacy-функции и выписать фактические поля, значения по умолчанию и обработку ошибок.',
'Проверить, подключён ли нужный Bitrix-модуль, и зафиксировать установленную версию вместе с документацией API.',
'Добавить characterization tests на текущий результат: успешное сохранение, пустое поле, повторный вызов и ошибку.',
'Выбрать keep, wrap или replace; для wrapper описать canonical input/output и список скрываемых legacy-деталей.',
'После изменения повторить те же проверки и отдельно проверить события, права и формат возвращаемого ID.',
]),
h2('Ограничения и следующий шаг'),
p('Эта статья не является планом миграции, политикой доказательств или инструкцией для конкретной CMS. Она не устанавливает, какие тесты обязательны, где хранить логи, какие данные безопасны и кто владеет legacy-системой. Официальные источники ниже помогают не размывать модальность и engineering boundary, но не добавляют недостающие факты. Непроверяемая архивная документация Bitrix не используется как факт именно потому, что version boundary не подтверждён.'),
p('Следующий шаг — только future owner с отдельным полномочием: он может признать hand-off достаточным, открыть discovery либо запросить иной контракт. Пока такого решения нет, безопасный результат уже достигнут в узком смысле: сформирован synthetic plan hand-off, а production effect не предпринимался. Это не положительный вывод о системе. Это отказ делать вид, что у будущего февраля 2027 уже есть тест, релиз и доказанный итог.')
], [{ key: 'rfc2119', use: 'Использована только точность требований и запретов в тексте передачи.', boundary: 'Не превращает hand-off в выполненную миграцию.' }, { key: 'rfc8174', use: 'Использовано различение контекста нормативного утверждения.', boundary: 'Не подтверждает evidence, API или тест.' }, { key: 'nist160', use: 'Использована общая дисциплина инженерного evidence и границ.', boundary: 'Не описывает реальный Bitrix-проект или release.' }]);
p('Без доступа к конкретной установке нельзя обещать совместимость версии, поведение событий или одинаковые тексты ошибок. Документация Bitrix описывает API, но не локальные обработчики и не поля, добавленные проектом. Полная замена особенно опасна, если legacy-вызов участвует в транзакции, импортирует данные или используется административной формой.'),
p('Следующий шаг — взять одну функцию с двумя callers и оформить её canonical контракт. Если тесты не удаётся написать без сложной среды, это сигнал оставить код на месте и сначала сократить границу побочных эффектов. Такая остановка тоже инженерное решение: она сохраняет обратимость и делает следующий commit проверяемым.'),
], [
{ key: 'cuser', use: 'Названия CUser, поля и наличие аналога UserTable сверены с документацией Bitrix.', boundary: 'Документация не описывает локальные события, права, обработчики и фактический набор callers проекта.' },
{ key: 'loader', use: 'Проверка подключения модуля до вызова API используется как отдельное условие адаптера.', boundary: 'Страница не подтверждает, что нужный модуль установлен в конкретной среде.' },
{ key: 'filter', use: 'PHP-проверка входных значений упомянута как часть нормализации boundary.', boundary: 'Manual не определяет Bitrix-поля и не заменяет тесты проекта.' },
]);
const mechanism = revision({
slug: 'editorial-2027-02-mechanism-bitrix-lessons',
title: 'Bitrix API и версия: имя метода не обещает одинаковый контракт',
categories: ['Bitrix', 'Архитектура'],
cover: '/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg',
excerpt: 'Как проверять установленную поверхность API и не считать название класса доказательством совместимости.',
readingMinutes: 16,
}, [
p('Проблема версионной совместимости выглядит как простой поиск: в документации найден метод, значит его можно вызвать. В Bitrix такой вывод часто слишком сильный. Один и тот же смысл может жить в legacy-классе и в D7, а конкретная установка может не содержать нужный модуль или иметь изменённое поле. Цена ошибки — адаптер, который компилируется и падает только на редкой форме или после обновления.'),
p('Надёжная граница строится из трёх фактов: модуль подключён, поверхность методов действительно доступна, а вход и выход совпадают с нужным проекту контрактом. Версия помогает сузить поиск, но не заменяет проверку. Даже правило Semantic Versioning применимо только там, где поставщик соблюдает его для данного API; имя <code>Update</code> само по себе не обещает семантическую совместимость.'),
h2('Подключение модуля — часть контракта'),
p('Документация Bitrix для <code>CModule::IncludeModule</code> прямо описывает проверку установки и подключения модуля. Это не декоративная строка перед вызовом. Если модуль не подключён, сообщение об ошибке может появиться далеко от места, где принято решение использовать API. В адаптере проверка должна быть близко к границе и возвращать понятный результат, который можно показать в диагностике.'),
p('Для D7 аналогично нужен конкретный namespace и набор методов. Нельзя заменить <code>CUser</code> на <code>Bitrix\\Main\\UserTable</code>, не проверив mapping полей, типы значений и обработку исключений. Хорошее сравнение описывает не названия классов, а операции: создать, обновить, найти, получить ID, обработать ошибку. Именно операции должны попасть в тестовую матрицу.'),
figure('/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg', 'Матрица версионной границы Bitrix: подключённый модуль, доступная поверхность, контракт операции и результат проверки.', 'Имя метода занимает только первый слой. До изменения нужно пройти до фактической поверхности установленной версии и сопоставить поля.'),
table('Уровни проверки Bitrix API', ['Уровень', 'Входной факт', 'Проверка', 'Риск пропуска'], [
['Модуль', 'iblock или main подключён', 'IncludeModule возвращает true', 'класс не загружен'],
['Поверхность', 'метод или таблица существуют', 'проверить установленный API', 'вызов неизвестного метода'],
['Поля', 'названия и типы совпадают', 'сопоставить mapping', 'тихая потеря значения'],
['Семантика', 'ошибка и результат понятны', 'characterization/regression test', 'новая форма ведёт себя иначе'],
]),
h2('Локальный инспектор поверхности'),
p('Чтобы сделать проверку повторяемой, можно сначала работать с маленьким manifest, полученным из конкретного окружения: версия, признак подключения и список доступных операций. Ниже функция принимает такой manifest и выбирает следующий технический шаг. Она не делает вид, что знает реальный сервер: данные нужно собрать командой проверки в самой среде, а функция только не даёт перепутать отсутствие модуля с отсутствием метода.'),
code(`import { inspectBitrixSurface } from './upgrade-2027-02.mjs';
const surfaces = [
{ version: '20.0', moduleLoaded: false, methods: [] },
{ version: '20.0', moduleLoaded: true, methods: ['CUser::GetByID', 'CUser::Update'] },
{ version: '23.0', moduleLoaded: true, methods: ['Bitrix\\Main\\UserTable'] },
];
for (const surface of surfaces) console.log(inspectBitrixSurface(surface));
// module-not-loaded
// legacy-surface-present
// d7-surface-present`),
p('Здесь важен порядок. Первый manifest не доходит до анализа методов: отсутствует базовое условие. Второй разрешает говорить только о наличии legacy-поверхности и требует явного адаптера. Третий показывает D7-поверхность, но не объявляет mapping полей готовым. Такой результат проще проверять в CI или в диагностической команде, чем свободный текст в issue.'),
h2('Поле важнее красивого имени'),
p('Самая тихая ошибка миграции — значение сохранилось, но стало другим. Телефон мог быть строкой с пробелами и плюсами, email — сохранён с исходным регистром, пустое поле — означать «очистить», а отсутствие поля — «не менять». Если новый API получает обычный объект без различия этих состояний, wrapper стирает смысл запроса. Поэтому contract table должна перечислять хотя бы value, empty и missing.'),
p('Version boundary нужно держать рядом с этой таблицей. Если в старой версии поле принимает строку, а новая модель отдаёт массив значений, название свойства не спасёт. Правильная проверка — пройти create/read/update на фиксированных данных и сравнить смысловой результат. Одинаковый ID после update ещё не доказывает, что события и индексы получили тот же input.'),
h2('Действия по порядку'),
ol([
'Зафиксировать версию ядра, подключаемый модуль и источник документации, на который опирается вызов.',
'Собрать manifest доступных методов или таблиц из той среды, где будет выполняться изменение.',
'Разложить операцию на поля, пустое значение, отсутствие поля, ошибку и побочный event.',
'Проверить legacy и D7 на одном наборе входов, не сравнивая только имена классов или финальный ID.',
'Оставить adapter boundary до тех пор, пока regression не подтвердит одинаковый смысл результата.',
]),
h2('Ограничения и следующий шаг'),
p('Инспектор не заменяет запуск в Bitrix: он не видит автозагрузку, права, события, local overrides и SQL-ограничения. Номер версии может быть установлен, но отдельный модуль — отсутствовать. Semantic Versioning тоже не заставляет внутренний API платформы соблюдать обещания внешнего пакета. Любое утверждение о совместимости должно опираться на конкретный набор окружений и операций.'),
p('Следующий шаг — добавить в проект диагностическую команду, которая печатает только безопасный manifest: версия, подключённый модуль и названия операций без данных пользователей. Затем используйте его перед миграцией одного метода. Если surface различается, адаптер должен остановить изменение с понятной причиной, а не подобрать метод по совпадению имени.'),
], [
{ key: 'loader', use: 'Официальная проверка подключения модуля используется как первый слой version boundary.', boundary: 'Документация не сообщает состояние конкретной установки и не описывает mapping полей.' },
{ key: 'cuser', use: 'CUser и его D7-аналог используются для различения поверхности API и операций пользователя.', boundary: 'Страница не обещает, что два класса равны по событиям, типам и ошибкам.' },
{ key: 'semver', use: 'Правило совместимости версий используется как оговорка о границах обещаний поставщика.', boundary: 'Спецификация не делает Bitrix API Semantic Versioning-совместимым автоматически.' },
]);
const field = revision({
slug: 'editorial-2027-02-field-bitrix-lessons',
title: 'Миграция Bitrix-поля: проверить сохранение, чтение и обратимость',
categories: ['Bitrix', 'Данные'],
cover: '/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg',
excerpt: 'Полевой чек-лист переноса пользовательского поля: mapping, пустые значения, повторный запуск и проверяемый результат.',
readingMinutes: 15,
}, [
p('Проблема миграции Bitrix-поля проявляется после успешного ответа API. Запись получила ID, но телефон оказался пустым, email изменил регистр, а повторный запуск создал второе значение. Цена ошибки — не только испорченная строка. Дальше ломается поиск, уведомление или связь с внешней системой, а восстановить исходное состояние трудно, потому что команда сохранила только факт «update вернул успех».'),
p('Полевой разбор должен проверять три операции: сохранить mapping, прочитать результат тем же смыслом и повторить вход без дубля. Для каждого поля нужно различить отсутствующее значение и явную очистку. Это особенно важно при переходе от массивов Bitrix к canonical объекту: старое имя можно удалить из кода, но нельзя удалить смысл значения до окончания проверки.'),
h2('Сначала таблица mapping'),
p('Документация CUser перечисляет поля пользователя, включая <code>PERSONAL_PHONE</code>, <code>EMAIL</code>, идентификатор и время изменения. Для проекта это отправная точка, а не готовая схема: рядом могут быть пользовательские поля, обработчики события и внешний XML_ID. Запишите для каждого значения источник, формат, пустое состояние и обратное представление. Если поле не переносится, причина должна быть явной.'),
p('Нормализация должна быть идемпотентной: одинаковый вход при повторном запуске даёт одинаковый canonical результат. Для телефона это может быть trim без изменения номера, для email — lowercase, если бизнес-правило считает регистр незначимым. Нельзя применять общую нормализацию ко всем полям: комментарий пользователя, парольный хэш и XML_ID имеют разные правила.'),
figure('/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg', 'Цикл проверки миграции Bitrix-поля: mapping входа, нормализация, запись, повторное чтение и контроль повторного запуска.', 'Схема показывает, что успешный вызов записи — только середина проверки. Нужны read-back и повторяемость результата.'),
table('Полевой контракт переноса поля', ['Поле', 'Legacy-значение', 'Canonical-значение', 'Проверка'], [
['Телефон', 'PERSONAL_PHONE, пробелы допустимы', 'phone, trimmed string', 'read-back и формат'],
['Email', 'EMAIL, исходный регистр', 'email, lower-case', 'валидность и отсутствие дубля'],
['ID', 'ID пользователя', 'externalId', 'одинаковая запись при retry'],
['Пустота', 'нет ключа или пустая строка', 'missing или clear', 'два разных теста'],
['Связь', 'XML_ID', 'externalId', 'двусторонний mapping'],
]),
h2('Учебная нормализация без Bitrix-сервера'),
p('Ниже запускается локальная функция преобразования одного объекта. Она сохраняет legacy-копию, создаёт canonical поля и возвращает признак обратимости. Это не подмена миграционного запуска: результат показывает, как тестировать mapping до подключения API. В интеграционном коде следующая проверка должна сравнить canonical объект с read-back из Bitrix.'),
code(`import { migrateUserFields } from './upgrade-2027-02.mjs';
const input = {
ID: 17,
PERSONAL_PHONE: ' +7 900 000-00-00 ',
EMAIL: 'User@Example.TEST',
XML_ID: 'crm-17',
};
console.log(migrateUserFields(input));
// canonical.phone === '+7 900 000-00-00'
// canonical.email === 'user@example.test'
// reversible === true`),
p('Ожидаемый результат показывает две отдельные операции: пробелы убраны у телефона, регистр email нормализован, а исходные поля остаются в legacy snapshot. Snapshot нужен для теста и отката преобразования, но не должен случайно отправляться обратно в новый API. В production-модуле его заменит журнал безопасного mapping без персональных значений.'),
h2('Пустое поле и отсутствующее поле'),
p('Разница между <code>{}</code> и <code>{ PERSONAL_PHONE: "" }</code> часто теряется в универсальном merge. Первый объект может означать «не менять телефон», второй — «очистить телефон». Если миграция смешивает эти случаи, повторный запуск удалит данные, которые не должны были меняться. В тестовой матрице должны быть оба входа и ожидаемое действие на стороне Bitrix.'),
p('Внешняя валидация тоже не должна менять значение молча. PHP <code>filter_var</code> может помочь проверить email, но решение о допустимости адреса принадлежит контракту приложения. Неверный email лучше остановить до update, чем сохранить пустую строку и потом считать ответ API доказательством успеха. Для телефона нужны отдельные правила: длина, допустимые символы и локальный формат.'),
h2('Действия по порядку'),
ol([
'Составить mapping table и отдельно назвать missing, empty, invalid и unchanged для каждого поля.',
'Запустить нормализацию на локальном наборе с пробелами, разным регистром, пустым и неверным значением.',
'В тестовой среде записать одну запись по стабильному ID или XML_ID, затем выполнить read-back.',
'Сравнить смысловые поля, время изменения, события и внешний идентификатор; не ограничиваться HTTP/API success.',
'Повторить тот же запуск и убедиться, что новая запись не появилась и canonical результат не изменился.',
]),
h2('Ограничения и следующий шаг'),
p('Локальная функция не знает о правах, событиях и особенностях конкретной версии Bitrix. Read-back может вернуть представление, отличное от входа: формат телефона, timezone или пустое значение иногда нормализуются сервером. Восстановление должно учитывать транзакцию и сохранённую связь, а не просто повторно отправлять старый объект.'),
p('Следующий шаг — взять одно поле, для которого есть внешний XML_ID, и прогнать полный цикл на небольшой выборке: mapping, запись, read-back, повтор и отчёт по расхождениям. Пока расхождения не классифицированы, расширять миграцию на весь набор рискованно. Проверяемость одного поля ценнее широкого запуска с неясным результатом.'),
], [
{ key: 'cuser', use: 'Официальный список полей CUser используется для примера PERSONAL_PHONE, EMAIL, ID и XML_ID.', boundary: 'Документация не знает пользовательские поля, события и фактические данные проекта.' },
{ key: 'filter', use: 'PHP Manual используется для проверки допустимости входного email перед записью.', boundary: 'filter_var не определяет бизнес-правила, Bitrix-формат и гарантию сохранения поля.' },
{ key: 'loader', use: 'Подключение модуля упомянуто как проверка интеграционной границы до записи.', boundary: 'Страница не подтверждает права и настройки конкретной среды.' },
]);
export const revisions = Object.freeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() {
const checks = revisions.map((item) => {
const body = bodyText(item.contentHtml);
return body.length >= 5000 && body.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body);
});
const sample = inspectBitrixSurface({ version: '20.0', moduleLoaded: true, methods: ['CUser::GetByID', 'CUser::Update'] });
const mapping = migrateUserFields({ PERSONAL_PHONE: ' 123 ', EMAIL: 'A@B.C' });
const fixtureOk = sample.status === 'legacy-surface-present' && mapping.canonical.email === 'a@b.c';
return Object.freeze({ passed: checks.filter(Boolean).length + (fixtureOk ? 1 : 0), total: checks.length + 1, accepted: checks.every(Boolean) && fixtureOk, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
export const revisions = deepFreeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() { const fixture = runFixedLessonFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|потер)/i.test(text.slice(0, 1000)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /2027-02/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); }
if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; }
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');