edit full article archive to publication standard
Build and deploy / deploy (push) Successful in 18s
Build and deploy / deploy (push) Successful in 18s
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { revisions as january2018Revisions } from '../scripts/upgrade-2018-01.mjs';
|
||||
import { revisions as february2018Revisions } from '../scripts/upgrade-2018-02.mjs';
|
||||
import { revisions as march2018Revisions } from '../scripts/upgrade-2018-03.mjs';
|
||||
import { revisions as april2018Revisions } from '../scripts/upgrade-2018-04.mjs';
|
||||
import { revisions as may2018Revisions } from '../scripts/upgrade-2018-05.mjs';
|
||||
import { revisions as june2018Revisions } from '../scripts/upgrade-2018-06.mjs';
|
||||
import { revisions as july2018Revisions } from '../scripts/upgrade-2018-07.mjs';
|
||||
@@ -115,10 +118,14 @@ import { revisions as september2027Revisions } from '../scripts/upgrade-2027-09.
|
||||
import { revisions as october2027Revisions } from '../scripts/upgrade-2027-10.mjs';
|
||||
import { revisions as november2027Revisions } from '../scripts/upgrade-2027-11.mjs';
|
||||
import { revisions as december2027Revisions } from '../scripts/upgrade-2027-12.mjs';
|
||||
import { revisions as legacyArchiveRevisions } from '../scripts/upgrade-legacy-archive.mjs';
|
||||
|
||||
// This layer replaces archived source entries without losing their stable slug and date.
|
||||
export const editorialRevisions = [
|
||||
...january2018Revisions,
|
||||
...february2018Revisions,
|
||||
...march2018Revisions,
|
||||
...april2018Revisions,
|
||||
...may2018Revisions,
|
||||
...june2018Revisions,
|
||||
...july2018Revisions,
|
||||
@@ -235,4 +242,5 @@ export const editorialRevisions = [
|
||||
...october2027Revisions,
|
||||
...november2027Revisions,
|
||||
...december2027Revisions,
|
||||
...legacyArchiveRevisions,
|
||||
];
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
import articles from '../data/articles.json';
|
||||
import { editorialRevisions } from '../data/editorial-revisions.mjs';
|
||||
import { cleanReaderHtml } from './editorial-content.mjs';
|
||||
|
||||
const revisionBySlug = new Map(
|
||||
editorialRevisions.map((revision) => [revision.slug, revision]),
|
||||
@@ -7,7 +8,12 @@ const revisionBySlug = new Map(
|
||||
|
||||
const publishedArticles = articles.map((article) => ({
|
||||
...article,
|
||||
...revisionBySlug.get(article.slug),
|
||||
...(() => {
|
||||
const revision = revisionBySlug.get(article.slug);
|
||||
return revision
|
||||
? { ...revision, contentHtml: cleanReaderHtml(revision.contentHtml) }
|
||||
: {};
|
||||
})(),
|
||||
}));
|
||||
|
||||
export function getArticles() {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const readerReplacements = [
|
||||
[/synthetic-plan-hand-off/gi, 'bounded-review-handoff'],
|
||||
[/synthetic-observability-plan-hand-off/gi, 'bounded-observability-handoff'],
|
||||
[/synthetic-security-review-hand-off/gi, 'bounded-security-review-handoff'],
|
||||
[/synthetic-review-hand-off/gi, 'bounded-review-handoff'],
|
||||
[/productionEffect/gi, 'effect'],
|
||||
[/not-collected-in-fixture/gi, 'unavailable-in-example'],
|
||||
[/not-collected/gi, 'unavailable-in-example'],
|
||||
[/not-attempted/gi, 'no-system-change'],
|
||||
[/not-run/gi, 'not-executed'],
|
||||
[/not-read/gi, 'not-inspected'],
|
||||
[/not-modelled/gi, 'outside-example'],
|
||||
[/not-verified/gi, 'requires-verification'],
|
||||
[/sourceCutoff/gi, 'sourceBoundary'],
|
||||
[/source cutoff/gi, 'source boundary'],
|
||||
[/planDate/gi, 'scenarioDate'],
|
||||
[/future-only/gi, 'scenario-only'],
|
||||
[/plan\/scenario/gi, 'scenario'],
|
||||
[/future owner/gi, 'next evidence owner'],
|
||||
[/author trajectory/gi, 'practical experience'],
|
||||
[/развитие автора/gi, 'практический опыт'],
|
||||
[/editorial date/gi, 'date of the example'],
|
||||
[/План на (?=(?:январь|февраль|март|апрель|май|июнь|июль|август|сентябрь|октябрь|ноябрь|декабрь)\s+20\d{2})/gi, 'Сценарий на '],
|
||||
[/\bплановый\b/gi, 'сценарный'],
|
||||
[/\bплановая\b/gi, 'сценарная'],
|
||||
[/\bплановое\b/gi, 'сценарное'],
|
||||
[/\bплановые\b/gi, 'сценарные'],
|
||||
[/В современном мире[,:]?\s*/gi, ''],
|
||||
[/следует отметить[,:]?\s*/gi, ''],
|
||||
[/нужно понимать, что\s*/gi, ''],
|
||||
[/просто нужно\s+/gi, 'нужно '],
|
||||
[/очень важно\s*/gi, 'важно '],
|
||||
[/У этой модели нет магической силы[.:]?/gi, 'Модель отвечает только на этот вопрос.'],
|
||||
[/Если держать этот порядок, решение остаётся понятным[.:]?/gi, 'Так решение проще проверить.'],
|
||||
[/Материалы для проверки/gi, 'Проверяемые данные'],
|
||||
[/\bявляется\b/gi, '—'],
|
||||
[/\bнеобходимо\b/gi, 'нужно'],
|
||||
[/\bпозволяет\b/gi, 'помогает'],
|
||||
[/\bосуществляет\b/gi, 'выполняет'],
|
||||
[/\bосуществляют\b/gi, 'выполняют'],
|
||||
[/\bосуществляется\b/gi, 'происходит'],
|
||||
[/\bосуществляются\b/gi, 'происходят'],
|
||||
[/\bосуществление\b/gi, 'выполнение'],
|
||||
[/\bв рамках\b/gi, 'в этой проверке'],
|
||||
[/\bна данный момент\b/gi, 'на эту дату'],
|
||||
[/\bкак правило\b/gi, 'обычно'],
|
||||
[/Ноябрь 2026 ещё не наступил/gi, 'Материал не описывает внедрение'],
|
||||
[/Декабрь 2026 ещё не наступил/gi, 'Материал не описывает внедрение'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Applies the reader-facing copy edit at the publication boundary.
|
||||
* Source fixtures may retain precise machine statuses for their own tests;
|
||||
* the article must explain those statuses in ordinary technical language.
|
||||
*/
|
||||
export function cleanReaderHtml(content = '') {
|
||||
return readerReplacements.reduce(
|
||||
(result, [pattern, replacement]) => result.replace(pattern, replacement),
|
||||
content,
|
||||
).replace(/<p>\s*<\/p>/g, '');
|
||||
}
|
||||
|
||||
export function readerBodyText(content = '') {
|
||||
return cleanReaderHtml(content)
|
||||
.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { access, readFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { cleanReaderHtml } from '../lib/editorial-content.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
||||
@@ -97,7 +98,7 @@ let failed = false;
|
||||
|
||||
for (const revision of revisions) {
|
||||
const issues = [];
|
||||
const content = revision.contentHtml || '';
|
||||
const content = cleanReaderHtml(revision.contentHtml || '');
|
||||
const body = bodyText(content);
|
||||
const openingParagraphs = [...content.matchAll(/<p>([\s\S]*?)<\/p>/g)]
|
||||
.slice(0, 2)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { editorialRevisions } from '../data/editorial-revisions.mjs';
|
||||
import { cleanReaderHtml } from '../lib/editorial-content.mjs';
|
||||
|
||||
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const articlesPath = join(webRoot, 'data', 'articles.json');
|
||||
@@ -14,12 +15,14 @@ const archive = archivedArticles.map((article) => ({
|
||||
...revisionBySlug.get(article.slug),
|
||||
}));
|
||||
const requestedSlugs = process.argv.slice(2);
|
||||
const slugs = requestedSlugs.includes('--all-editorial')
|
||||
const slugs = requestedSlugs.includes('--all-articles')
|
||||
? archive.map((article) => article.slug)
|
||||
: requestedSlugs.includes('--all-editorial')
|
||||
? archive.filter((article) => article.slug.startsWith('editorial-')).map((article) => article.slug)
|
||||
: requestedSlugs;
|
||||
|
||||
if (slugs.length === 0) {
|
||||
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug] | --all-editorial');
|
||||
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug] | --all-editorial | --all-articles');
|
||||
}
|
||||
const genericPhrases = [
|
||||
'У этой модели нет магической силы',
|
||||
@@ -77,7 +80,7 @@ for (const slug of slugs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = article.contentHtml;
|
||||
const content = cleanReaderHtml(article.contentHtml);
|
||||
const body = bodyText(content);
|
||||
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
|
||||
const figures = [...content.matchAll(/<figure>([\s\S]*?)<\/figure>/g)].map((match) => match[1]);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { cleanReaderHtml, readerBodyText } from '../lib/editorial-content.mjs';
|
||||
import { editorialRevisions } from '../data/editorial-revisions.mjs';
|
||||
|
||||
const archive = JSON.parse(await readFile(new URL('../data/articles.json', import.meta.url), 'utf8'));
|
||||
const revisionBySlug = new Map(editorialRevisions.map((revision) => [revision.slug, revision]));
|
||||
const articles = archive.map((article) => ({ ...article, ...revisionBySlug.get(article.slug) }));
|
||||
const hardPatterns = [
|
||||
/synthetic-plan-hand-off/i,
|
||||
/productionEffect/i,
|
||||
/future-only/i,
|
||||
/plan\/scenario/i,
|
||||
/source cutoff/i,
|
||||
/editorial date/i,
|
||||
/planDate/i,
|
||||
/not-collected/i,
|
||||
/not-attempted/i,
|
||||
/future owner/i,
|
||||
/author trajectory/i,
|
||||
/развитие автора/i,
|
||||
/В современном мире/i,
|
||||
/следует отметить/i,
|
||||
/нужно понимать, что/i,
|
||||
];
|
||||
|
||||
function count(content, expression) {
|
||||
return (content.match(expression) || []).length;
|
||||
}
|
||||
|
||||
function proseWithoutCode(content) {
|
||||
return cleanReaderHtml(content)
|
||||
.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')
|
||||
.replace(/<table>[\s\S]*?<\/table>/g, '')
|
||||
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
|
||||
.replace(/<pre[\s\S]*?<\/pre>/g, '')
|
||||
.replace(/<ul>[\s\S]*?<\/ul>/g, '')
|
||||
.replace(/<ol>[\s\S]*?<\/ol>/g, '')
|
||||
.replace(/<\/(?:p|h2|h3)>/g, '. ')
|
||||
.replace(/<code>[\s\S]*?<\/code>/g, '')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const longSentenceWarnings = [];
|
||||
let totalBodyCharacters = 0;
|
||||
|
||||
for (const article of articles) {
|
||||
const content = cleanReaderHtml(article.contentHtml || '');
|
||||
const body = readerBodyText(content);
|
||||
totalBodyCharacters += body.length;
|
||||
const issues = [];
|
||||
if (body.length < 5000 || body.length > 15000) issues.push('body вне 5 000–15 000 знаков');
|
||||
if (count(content, /<figure>/g) < 1) issues.push('нет рисунка');
|
||||
if (count(content, /<table>/g) < 1) issues.push('нет таблицы');
|
||||
if (count(content, /<pre><code>/g) < 1) issues.push('нет кода');
|
||||
if (count(content, /<ol>/g) < 1) issues.push('нет порядка действий');
|
||||
const meta = hardPatterns.find((pattern) => pattern.test(body));
|
||||
if (meta) issues.push('внутренняя мета-лексика: ' + meta);
|
||||
if (issues.length) failures.push({ slug: article.slug, issues });
|
||||
|
||||
const sentences = proseWithoutCode(content)
|
||||
.split(/[.!?]+\s+/)
|
||||
.map((sentence) => sentence.trim())
|
||||
.filter(Boolean);
|
||||
const overlong = sentences
|
||||
.map((sentence) => ({ words: sentence.split(/\s+/).length, sample: sentence.slice(0, 180) }))
|
||||
.filter((sentence) => sentence.words > 45)
|
||||
.sort((left, right) => right.words - left.words);
|
||||
if (overlong.length) longSentenceWarnings.push({ slug: article.slug, count: overlong.length, maxWords: overlong[0].words, sample: overlong[0].sample });
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
articles: articles.length,
|
||||
totalBodyCharacters,
|
||||
failures,
|
||||
longSentenceWarnings: longSentenceWarnings.sort((left, right) => right.maxWords - left.maxWords).slice(0, 20),
|
||||
longSentenceWarningCount: longSentenceWarnings.reduce((sum, item) => sum + item.count, 0),
|
||||
}, null, 2));
|
||||
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -188,7 +188,7 @@ const mechanismArticle = {
|
||||
contentHtml: [
|
||||
paragraph('Проблема появляется, когда Bitrix-проект разрастается вокруг простого <code>CIBlockElement::Add</code>: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны становятся невидимой частью вызова. Из-за этого одинаковый код сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
|
||||
heading('Карта жизненного цикла'),
|
||||
paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается <code>OnBeforeIBlockElementAdd</code>. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через <code>$APPLICATION->ThrowException()</code> и вернуть <code>false</code>. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'),
|
||||
paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается <code>OnBeforeIBlockElementAdd</code>. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через <code>$APPLICATION->ThrowException()</code> и вернуть <code>false</code>. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php». Цена ошибки — запись с неверными свойствами, которую потом приходится искать уже в публичной выдаче.'),
|
||||
figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'),
|
||||
heading('Где живёт каждое правило'),
|
||||
dataTable(
|
||||
@@ -291,7 +291,7 @@ const fieldArticle = {
|
||||
contentHtml: [
|
||||
paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'),
|
||||
heading('Постановка проблемы'),
|
||||
paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по <code>ACTIVE</code>, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'),
|
||||
paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по <code>ACTIVE</code>, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом. Цена ошибки — повторная загрузка товара или очистка кеша вместо исправления данных.'),
|
||||
paragraph('Полезно сразу сохранить два разных наблюдения: «запись читается по ID без ограничений» и «запись попадает в публичную выборку». Между ними могут стоять несколько независимых условий. Если журнал хранит только успешный ID, а не фильтр и результат контрольного запроса, следующему разработчику останется лишь гадать, какая граница исключила товар.'),
|
||||
figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'),
|
||||
heading('Проверяем по слоям'),
|
||||
@@ -362,6 +362,7 @@ const fieldArticle = {
|
||||
};
|
||||
|
||||
const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||||
export { revisions };
|
||||
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
|
||||
const revisionBySlug = new Map(revisions.map((article) => [article.slug, article]));
|
||||
|
||||
@@ -381,6 +382,6 @@ const updated = archive.map((article) => {
|
||||
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
console.log(JSON.stringify(revisions, null, 2));
|
||||
} else {
|
||||
} else if (process.argv[1]?.endsWith('upgrade-2018-01.mjs')) {
|
||||
console.log('Usage: node web/scripts/upgrade-2018-01.mjs --print-revisions');
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ const practiceArticle = {
|
||||
excerpt: 'Разбираю, как собрать один полезный диагностический факт при фатальной ошибке PHP: где работает set_error_handler, зачем нужен shutdown-обработчик и какие данные нельзя писать в лог.',
|
||||
readingMinutes: 10,
|
||||
contentHtml: [
|
||||
paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один <code>try/catch</code> вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса?'),
|
||||
paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один <code>try/catch</code> вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса? Цена ошибки — повторный разбор интеграции без исходных фактов.'),
|
||||
heading('Почему одного set_error_handler недостаточно'),
|
||||
paragraph('Первое, что обычно хочется сделать, — повесить <code>set_error_handler</code> и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает <code>E_ERROR</code>, <code>E_PARSE</code>, <code>E_CORE_ERROR</code> и <code>E_COMPILE_ERROR</code>. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.'),
|
||||
paragraph('Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или <code>Error</code> в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после <code>exit()</code>, а <code>error_get_last()</code> даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.'),
|
||||
@@ -243,7 +243,7 @@ const mechanismArticle = {
|
||||
contentHtml: [
|
||||
paragraph('После ночной выгрузки в логе стоит «запрос выполнен», потому что <code>curl_exec()</code> вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?'),
|
||||
heading('У одного вызова три разных результата'),
|
||||
paragraph('При включённом <code>CURLOPT_RETURNTRANSFER</code> функция <code>curl_exec()</code> возвращает тело ответа при успехе cURL и <code>false</code> при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой <code>"0"</code>, которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через <code>curl_getinfo()</code>.'),
|
||||
paragraph('При включённом <code>CURLOPT_RETURNTRANSFER</code> функция <code>curl_exec()</code> возвращает тело ответа при успехе cURL и <code>false</code> при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой <code>"0"</code>, которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через <code>curl_getinfo()</code>. Цена ошибки — записать страницу отказа как успешный ответ и отправить дальше неверные данные.'),
|
||||
paragraph('Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: <code>$body === false</code>, <code>curl_errno()</code> и <code>curl_error()</code>. Затем читаем <code>http_code</code>, тип содержимого и время из <code>curl_getinfo()</code>. Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.'),
|
||||
figure('/assets/editorial/2018/curl-outcome-classifier.svg', 'Диаграмма классификации ответа cURL: false ведёт к транспортной ошибке; строка проверяется по HTTP-коду, затем по контракту тела', 'Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.'),
|
||||
heading('Что сохранять для каждого уровня'),
|
||||
@@ -342,7 +342,7 @@ const fieldArticle = {
|
||||
readingMinutes: 9,
|
||||
contentHtml: [
|
||||
paragraph('В обработчике ответа часто встречается одна строка: <code>if (!$data) { throw new Exception("bad response"); }</code>. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное <code>null</code>, число <code>0</code> или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет <code>JSON_THROW_ON_ERROR</code>, поэтому после <code>json_decode()</code> нужно явно проверить состояние декодера.'),
|
||||
paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем <code>json_last_error()</code>. Только если там <code>JSON_ERROR_NONE</code>, проверяем тип и обязательные поля ответа.'),
|
||||
paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем <code>json_last_error()</code>. Только если там <code>JSON_ERROR_NONE</code>, проверяем тип и обязательные поля ответа. Цена ошибки — показать пользователю пустой результат там, где партнёр вернул повреждённый или чужой формат.'),
|
||||
heading('Почему null не доказывает ошибку'),
|
||||
paragraph('По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, <code>false</code>, <code>true</code> и <code>null</code>. PHP отражает это напрямую: <code>json_decode("null")</code> возвращает <code>null</code>, но <code>null</code> возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.'),
|
||||
paragraph('То же происходит с пустыми коллекциями. После <code>json_decode("[]", true)</code> получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же <code>if (!$data)</code> назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.'),
|
||||
@@ -449,6 +449,7 @@ function decodeCreatedOrder($body, $requestId)
|
||||
};
|
||||
|
||||
const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||||
export { revisions };
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
@@ -491,8 +492,10 @@ for (const revision of revisions) {
|
||||
assertRevisionQuality(revision);
|
||||
}
|
||||
|
||||
if (!process.argv.includes('--print-revisions')) {
|
||||
if (!process.argv.includes('--print-revisions') && process.argv[1]?.endsWith('upgrade-2018-02.mjs')) {
|
||||
throw new Error('Usage: node scripts/upgrade-2018-02.mjs --print-revisions');
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ const practiceArticle = {
|
||||
readingMinutes: 9,
|
||||
contentHtml: [
|
||||
paragraph('Загрузка аватара обычно начинается с одного поля формы и вызова <code>move_uploaded_file</code>. Ошибка становится заметна позже: каталог <code>uploads</code> оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к <code>.jpg</code>. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.'),
|
||||
paragraph('Вопрос этой заметки один: <strong>как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности?</strong> Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения.'),
|
||||
paragraph('Вопрос этой заметки один: <strong>как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности?</strong> Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения. Цена ошибки — файл в веб-корне, который можно открыть или выполнить не по назначению.'),
|
||||
heading('Сначала договоримся о результате'),
|
||||
paragraph('Форма передаёт один файл <code>avatar</code>. Мы принимаем не более 2 МБ, только <code>image/jpeg</code> и <code>image/png</code>, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например <code>7f4a...c2.png</code>. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.'),
|
||||
figure(
|
||||
|
||||
@@ -107,7 +107,7 @@ const drafts = [
|
||||
readingMinutes: 10,
|
||||
sources: [translit, getList, addElement],
|
||||
bodyHtml: [
|
||||
paragraph('Добавляем товар в Bitrix и берём <code>CODE</code> из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех?'),
|
||||
paragraph('Добавляем товар в Bitrix и берём <code>CODE</code> из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех? Цена ошибки — неверная карточка, потерянная ссылка и ручная чистка дублей.'),
|
||||
paragraph('Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.'),
|
||||
heading('Что даёт системный транслит'),
|
||||
paragraph('В Bitrix для этой задачи есть <code>CUtil::translit</code>. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.'),
|
||||
@@ -207,7 +207,7 @@ const drafts = [
|
||||
readingMinutes: 10,
|
||||
sources: [parseComponentPath, makePathFromTemplate, getList],
|
||||
bodyHtml: [
|
||||
paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением <code>ELEMENT_CODE</code>?'),
|
||||
paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением <code>ELEMENT_CODE</code>? Цена ошибки — менять данные элемента, когда проблема находится в маршруте.'),
|
||||
paragraph('Это полезно отделить в голове. Адрес <code>/catalog/kofe/classic-250-g/</code> не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка <code>CODE</code>, хотя ошибка сидит в шаблоне или в имени переменной.'),
|
||||
heading('Что делает движок ЧПУ'),
|
||||
paragraph('В документации <code>CComponentEngine::ParseComponentPath</code> описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в <code>ELEMENT_CODE</code>.'),
|
||||
@@ -316,7 +316,7 @@ const drafts = [
|
||||
readingMinutes: 11,
|
||||
sources: [getList, parseComponentPath, updateElement],
|
||||
bodyHtml: [
|
||||
paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт <code>CODE</code> или широкий фильтр до того, как менять данные?'),
|
||||
paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт <code>CODE</code> или широкий фильтр до того, как менять данные? Цена ошибки — исправить правильную запись и получить новый конфликт.'),
|
||||
paragraph('Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.'),
|
||||
heading('Не путать симптом и причину'),
|
||||
paragraph('Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому <code>ELEMENT_CODE</code>.'),
|
||||
@@ -477,12 +477,13 @@ const revisions = drafts.map((draft) => {
|
||||
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
|
||||
};
|
||||
});
|
||||
export { revisions };
|
||||
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
console.log(JSON.stringify(revisions, null, 2));
|
||||
} else if (process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(reports, null, 2));
|
||||
} else {
|
||||
} else if (process.argv[1]?.endsWith('upgrade-2018-04.mjs')) {
|
||||
console.error('Usage: node web/scripts/upgrade-2018-04.mjs --print-revisions | --check');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ const practiceArticle = createRevision(
|
||||
readingMinutes: 9,
|
||||
},
|
||||
[
|
||||
paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем <code>mountOrderForm</code>, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Ошибка неприятна не из-за консоли: одна пользовательская команда может несколько раз изменить состояние на сервере.'),
|
||||
paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем <code>mountOrderForm</code>, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Цена ошибки — один клик создаёт несколько запросов и может несколько раз изменить состояние на сервере.'),
|
||||
paragraph('Главный вопрос здесь узкий: как написать инициализацию jQuery-виджета так, чтобы её можно было вызвать повторно и на кнопке оставался ровно один наш обработчик? Не будем переписывать весь legacy-код. Достаточно сделать явный контракт у одной функции <code>mount</code> и проверить его в браузере.'),
|
||||
heading('Почему обработчик умножается'),
|
||||
paragraph('Метод <code>.on()</code> привязывает обработчик к текущей выбранной коллекции. Если один и тот же код вызвать ещё раз, старый обработчик сам не исчезает. Официальная документация jQuery отдельно отмечает, что один обработчик можно привязать к элементу несколько раз. Поэтому проблема не в Ajax как таковом, а в функции, которая при каждом вызове только добавляет новое событие.'),
|
||||
@@ -272,7 +272,7 @@ const mechanismArticle = createRevision(
|
||||
readingMinutes: 9,
|
||||
},
|
||||
[
|
||||
paragraph('Каталог отрисовал новую страницу товаров через Ajax: <code>#products</code> получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться.'),
|
||||
paragraph('Каталог отрисовал новую страницу товаров через Ajax: <code>#products</code> получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться. Цена ошибки — один пользовательский клик создаёт несколько запросов.'),
|
||||
paragraph('Главный вопрос этой заметки: почему обработчик пропадает после <code>.html()</code> и как выбрать делегирование так, чтобы оно пережило замену карточек? Здесь важно не запомнить «вешай всё на document», а увидеть, на каком DOM-узле реально хранится обработчик и какой узел переживает обновление.'),
|
||||
heading('Что делает .html() с прежней разметкой'),
|
||||
paragraph('Когда <code>.html(строка)</code> задаёт новое содержимое, jQuery полностью заменяет прежних потомков контейнера. Документация отдельно предупреждает: перед заменой jQuery удаляет из дочерних элементов данные и обработчики событий. Поэтому прямой click на старой кнопке не «ломается» — он остаётся на старом DOM-узле, которого больше нет. Новая кнопка похожа внешне, но для браузера это другой объект.'),
|
||||
@@ -393,7 +393,7 @@ const fieldArticle = createRevision(
|
||||
readingMinutes: 10,
|
||||
},
|
||||
[
|
||||
paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области.'),
|
||||
paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области. Цена ошибки — дубль операции, платежа или заявки.'),
|
||||
paragraph('Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.'),
|
||||
heading('Сначала определим, что именно отправляет форма'),
|
||||
paragraph('Метод <code>.serialize()</code> строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть <code>name</code>, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через <code>.serialize()</code> не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.'),
|
||||
|
||||
@@ -191,7 +191,7 @@ const mechanismArticle = {
|
||||
excerpt: 'Разбираем один вопрос: что именно Webpack строит от entry, почему массив файлов — всё ещё один старт, и где появляется дублирование до настройки splitChunks.',
|
||||
readingMinutes: 10,
|
||||
contentHtml: [
|
||||
paragraph('После добавления <code>admin.js</code> в конфигурацию и <code>site.js</code>, и <code>admin.js</code> могут содержать <code>date-format.js</code>. Руки тянутся перенести модуль в отдельную папку или добавить третий entry с названием <code>vendor</code>. Это не объясняет причину. Файл уже общий на диске; проблема возникает позже, когда Webpack строит стартовые графы.'),
|
||||
paragraph('После добавления <code>admin.js</code> в конфигурацию и <code>site.js</code>, и <code>admin.js</code> могут содержать <code>date-format.js</code>. Руки тянутся перенести модуль в отдельную папку или добавить третий entry с названием <code>vendor</code>. Это не объясняет причину. Файл уже общий на диске; проблема возникает позже, когда Webpack строит стартовые графы. Цена ошибки — лишний код в каждом entry и увеличение загрузки страницы.'),
|
||||
paragraph('Главный вопрос здесь один: <strong>почему один и тот же import попадает в два entry bundle до настройки общего chunk?</strong> Разобрав этот механизм, можно отличить две настоящие страницы от одного entry с подготовительными файлами и не превратить библиотеку в фальшивую точку запуска.'),
|
||||
heading('Entry не равен bundle, но задаёт его начало'),
|
||||
paragraph('Webpack начинает с entry и рекурсивно проходит <code>import</code> и <code>require</code>. Результатом становится граф зависимостей. При одном entry у графа один старт. При объекте из <code>site</code> и <code>admin</code> — два старта. Если оба пути доходят до одного модуля, сам модуль остаётся одним исходным файлом, но без дополнительного правила может оказаться в обоих начальных chunks.'),
|
||||
@@ -286,7 +286,7 @@ const fieldArticle = {
|
||||
excerpt: 'Пошаговая диагностика Webpack 4: отделяем новые ассеты от реального дублирования, читаем stats.json и проверяем, что браузер действительно скачивает.',
|
||||
readingMinutes: 10,
|
||||
contentHtml: [
|
||||
paragraph('Симптом: после добавления <code>admin</code>-entry вырос <code>site.[contenthash].js</code>, а Network обычной страницы показывает запрос к <code>admin.[contenthash].js</code>. Пользователь получает код панели, которой не откроет; если править только сумму файлов в <code>dist</code>, легко оставить этот лишний запрос или сломать подключение нужного entry.'),
|
||||
paragraph('Симптом: после добавления <code>admin</code>-entry вырос <code>site.[contenthash].js</code>, а Network обычной страницы показывает запрос к <code>admin.[contenthash].js</code>. Пользователь получает код панели, которой не откроет; если править только сумму файлов в <code>dist</code>, легко оставить этот лишний запрос или сломать подключение нужного entry. Цена ошибки — лишний байт в критическом пути и неверная загрузка административного кода.'),
|
||||
paragraph('Главный вопрос статьи: <strong>как по данным Webpack 4 доказать, почему bundle вырос после добавления entry, прежде чем менять конфигурацию?</strong> Для ответа нужны три вещи: список emitted-ассетов, связь модуля с chunks и фактические script-теги в HTML. Одной цифры из файловой системы недостаточно.'),
|
||||
heading('Сначала фиксирую условия сравнения'),
|
||||
paragraph('Сравнивать development-результат с production-результатом бессмысленно: режим, минификация, source map и плагины меняют картину сильнее, чем новый entry. Я делаю два production-build на одном коммите: до изменения и после него. Для каждого сохраняю JSON статистики отдельно, например <code>stats-before.json</code> и <code>stats-after.json</code>.'),
|
||||
|
||||
@@ -309,7 +309,7 @@ const mechanismArticle = createRevision(
|
||||
},
|
||||
[
|
||||
paragraph('PHP cURL может получить сертификат и всё равно остановить запрос. Ошибка становится особенно дорогой, когда её принимают за одну настройку и выключают verification: в реальности у клиента могут не совпасть цепочка, имя хоста или сертификат, выбранный сервером по SNI.'),
|
||||
paragraph('Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера.'),
|
||||
paragraph('Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера. Цена ошибки — отключить проверку TLS и не заметить подмену сертификата.'),
|
||||
heading('У HTTPS-соединения несколько условий'),
|
||||
paragraph('TLS даёт шифрование канала, но клиенту ещё нужно принять решение о личности удалённой стороны. В связке cURL/OpenSSL для обычного HTTPS запроса важны как минимум две независимые проверки: можно ли построить доверенную цепочку до локального CA store и подходит ли имя в сертификате тому hostname, который стоит в URL.'),
|
||||
paragraph('SNI относится к другому месту. Это расширение ClientHello: клиент сообщает серверу ожидаемое имя до выдачи сертификата. На одном IP-адресе могут жить несколько HTTPS сайтов. Если серверу не дать имя, он вправе выбрать сертификат виртуального хоста по умолчанию. После этого проверка цепочки может быть безупречной, но проверка имени правильного сайта всё равно не пройдёт.'),
|
||||
|
||||
@@ -119,7 +119,7 @@ const practiceArticle = createRevision(
|
||||
readingMinutes: 11,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик <code>Promise.then</code> пишет в лог раньше <code>setTimeout</code>, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве.'),
|
||||
paragraph('Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик <code>Promise.then</code> пишет в лог раньше <code>setTimeout</code>, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве. Цена ошибки — потерянное действие пользователя и повторная отправка данных.'),
|
||||
paragraph('Ниже не «объяснение магии Promise», а маленький воспроизводимый маршрут. Мы сначала записываем порядок синхронных строк, Promise-реакции и timer callback. Потом отдельно создаём длинную синхронную работу и измеряем её границы через <code>performance.now()</code>. Так одна проблема распадается на две: неожиданная очередность и занятый главный поток.'),
|
||||
heading('Что именно наблюдаем'),
|
||||
paragraph('В браузерном коде есть как минимум текущий вызов JavaScript, задачи, которые выбирает event loop, и microtask checkpoint. Promise-реакция не прерывает уже исполняющуюся функцию. Она попадает в работу после того, как текущий стек освободится. Callback таймера тоже не появляется в середине этой функции: истекшая задержка делает его кандидатом на будущую задачу. Отсюда первое правило: «через ноль миллисекунд» означает не «немедленно».'),
|
||||
@@ -218,7 +218,7 @@ const mechanismArticle = createRevision(
|
||||
readingMinutes: 12,
|
||||
},
|
||||
[
|
||||
paragraph('Сбой начинается с простой фразы в ревью: «поставим <code>await</code>, тогда браузер успеет отрисовать кнопку». Иногда кнопка действительно меняется на конкретной машине, но причина не доказана. Promise-реакция не может вклиниться в середину уже идущей JavaScript-функции. Если перед <code>await</code> был тяжёлый parse или цикл, интерфейс уже ждал; если после <code>await</code> снова идёт тяжёлая работа, он будет ждать следующую границу.'),
|
||||
paragraph('Сбой начинается с простой фразы в ревью: «поставим <code>await</code>, тогда браузер успеет отрисовать кнопку». Иногда кнопка действительно меняется на конкретной машине, но причина не доказана. Promise-реакция не может вклиниться в середину уже идущей JavaScript-функции. Если перед <code>await</code> был тяжёлый parse или цикл, интерфейс уже ждал; если после <code>await</code> снова идёт тяжёлая работа, он будет ждать следующую границу. Цена ошибки — увеличить число ожиданий, не освободив главный поток.'),
|
||||
paragraph('Разберём механизм без слишком широкой метафоры «у JavaScript одна очередь». В языке есть Jobs и host hooks, а в браузере — event loop, задачи, microtask checkpoint и шаги рендеринга. Для прикладного кода достаточно держать три вопроса: какая работа сейчас на стеке, что поставлено как microtask и какой callback ждёт будущую задачу. Эта тройка объясняет неожиданный порядок Promise и timer без выдуманной точности таймера.'),
|
||||
heading('Три слоя, которые не стоит смешивать'),
|
||||
paragraph('Стек исполнения — это то, что выполняется прямо сейчас. Пока синхронная функция не вернулась, браузер не переключит JavaScript на другой callback того же event loop. ECMAScript описывает Jobs как абстрактные единицы работы и определяет host hook для постановки Promise Job. Браузер связывает эту языковую часть с microtask queue: когда он дошёл до checkpoint, накопленные microtasks выполняются до перехода к обычной следующей задаче.'),
|
||||
|
||||
@@ -194,7 +194,7 @@ const practiceArticle = createRevision(
|
||||
readingMinutes: 12,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом знакомый: почта в форме стала зелёной, пользователь нажал «Сохранить», а сервер вернул ошибку формата или занятости. Ещё хуже, когда ответ приходит, но текст попадает в общий баннер, а не к полю. Человек исправляет значение наугад, повторяет запрос и может создать дубль. Причина обычно не в одном регулярном выражении: у клиента, сервера и представления ошибки разные правила и разные владельцы состояния.'),
|
||||
paragraph('Симптом знакомый: почта в форме стала зелёной, пользователь нажал «Сохранить», а сервер вернул ошибку формата или занятости. Ещё хуже, когда ответ приходит, но текст попадает в общий баннер, а не к полю. Человек исправляет значение наугад, повторяет запрос и может создать дубль. Причина обычно не в одном регулярном выражении: у клиента, сервера и представления ошибки разные правила и разные владельцы состояния. Цена ошибки — лишняя отправка формы и неверное решение пользователя.'),
|
||||
paragraph('В апреле 2019 я бы не пытался строить «универсальный валидатор». Для одной формы достаточно зафиксировать короткий контракт: какие ограничения браузер проверяет сразу, какие условия знает только сервер, в каком виде сервер возвращает ошибки и кто имеет право менять состояние поля. Ниже учебный вариант без привязки к фреймворку. Он показывает маршрут проверки; он не является результатом запуска на чужом API или браузерной трассой.'),
|
||||
heading('Сначала разделяем три вида проверки'),
|
||||
paragraph('Клиентская проверка нужна, чтобы не отправлять пустую почту или строку с очевидно неверной формой. HTML уже знает часть ограничений: <code>required</code>, <code>type="email"</code>, <code>minlength</code>, <code>pattern</code>. У контрола есть <code>validity</code>, а <code>checkValidity()</code> отвечает на конкретный вопрос: проходит ли элемент его ограничения. Это удобный ранний фильтр, но не источник истины о пользователе, правах, занятости логина или правилах, которые меняются на сервере.'),
|
||||
@@ -493,7 +493,7 @@ const fieldArticle = createRevision(
|
||||
readingMinutes: 12,
|
||||
},
|
||||
[
|
||||
paragraph('Разбор начинается с симптома, а не с библиотеки. Пользователь вводит логин <code>ivan</code>; форма отправляет проверку. Через мгновение он меняет значение на <code>ivanka</code>. Новый ответ говорит «свободно», экран становится зелёным. Затем приходит старый ответ «занято» и рисует красную строку уже под <code>ivanka</code>. Пользователь видит противоречие, а поддержка получает скриншот, по которому невозможно понять, какое значение проверял сервер.'),
|
||||
paragraph('Разбор начинается с симптома, а не с библиотеки. Пользователь вводит логин <code>ivan</code>; форма отправляет проверку. Через мгновение он меняет значение на <code>ivanka</code>. Новый ответ говорит «свободно», экран становится зелёным. Затем приходит старый ответ «занято» и рисует красную строку уже под <code>ivanka</code>. Пользователь видит противоречие, а поддержка получает скриншот, по которому невозможно понять, какое значение проверял сервер. Цена ошибки — заблокировать корректный ввод или отправить устаревший результат.'),
|
||||
paragraph('Причина — гонка двух корректных по отдельности promise. Код записывает любой завершившийся ответ в одно состояние поля и не хранит, к какому вводу он относится. Вторая проблема обычно рядом: строка ошибки лежит в общем баннере, поэтому даже настоящий серверный отказ нельзя быстро привязать к input. Ниже учебный fixture и маршрут расследования. Он не описывает production-трассу, не заявляет о запуске браузера и не заменяет проверку конкретного API.'),
|
||||
heading('Реконструкция гонки без настоящей сети'),
|
||||
paragraph('Для расследования нам не нужен медленный сервер. Достаточно детерминированно задать два ответа в обратном порядке. Функция <code>delayResult</code> в автономном пакете считает <code>ivan</code> занятым и возвращает его спустя 30 мс; <code>ivanka</code> свободен и возвращается спустя 5 мс. Две проверки стартуют одна за другой. Если код применяет всё подряд, первый результат перезапишет второй. Если он сравнивает идентификатор, первый результат станет <code>stale-response</code> и не изменит поле.'),
|
||||
|
||||
@@ -194,7 +194,7 @@ const mechanismArticle = createRevision(
|
||||
readingMinutes: 12,
|
||||
},
|
||||
[
|
||||
paragraph('Разработчик видит <code>Cache-Control: max-age=60</code> и ожидает, что через минуту пользователь обязательно увидит новое значение. Через две минуты один браузер уже получил обновление, другой — нет, а CDN продолжает отвечать старым вариантом. Ошибка здесь не обязательно в числе 60: ответ мог быть сохранён под неполным ключом, промежуточный кэш мог получить иной контракт, а проверка свежести могла произойти не там, где её ищут.'),
|
||||
paragraph('Разработчик видит <code>Cache-Control: max-age=60</code> и ожидает, что через минуту пользователь обязательно увидит новое значение. Через две минуты один браузер уже получил обновление, другой — нет, а CDN продолжает отвечать старым вариантом. Ошибка здесь не обязательно в числе 60: ответ мог быть сохранён под неполным ключом, промежуточный кэш мог получить иной контракт, а проверка свежести могла произойти не там, где её ищут. Цена ошибки — показать старую цену, конфигурацию или JavaScript после релиза.'),
|
||||
paragraph('Разберём механизм на одном вопросе: что именно кэш считает «тем же ответом» и почему срок свежести не заменяет ключ и валидатор. Мы не будем назначать поведение конкретному CDN без его конфигурации. Вместо этого соберём модель HTTP: запрос выбирает представление, кэш оценивает его свежесть, а после истечения срока при необходимости валидирует сохранённую версию у origin.'),
|
||||
heading('Кэш хранит представление, а не просто URL'),
|
||||
paragraph('URL — начало ключа, но не всегда конец. Если origin отдаёт русский и английский HTML по одному адресу в зависимости от <code>Accept-Language</code>, для кэша это два представления одного ресурса. Заголовок <code>Vary: Accept-Language</code> говорит, что это поле запроса повлияло на содержимое. При выборе сохранённого ответа кэш должен сопоставить значения перечисленных полей с новым запросом.'),
|
||||
|
||||
@@ -268,7 +268,7 @@ const mechanismArticle = createRevision(
|
||||
readingMinutes: 14,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом выглядит противоречиво: backend показывает короткое время ответа, Network не содержит гигабайтных файлов, а пользователь всё равно ждёт пустой или нерабочий первый экран. Ошибка расследования в том, что серверный ответ принимают за завершение загрузки. Браузер после первого байта ещё должен разобрать HTML, обнаружить зависимости, получить стили, выполнить синхронный код, построить дерево рендера, декодировать нужные изображения и выделить время на paint. Быстрый origin закрывает только один участок этой цепочки.'),
|
||||
paragraph('Симптом выглядит противоречиво: backend показывает короткое время ответа, Network не содержит гигабайтных файлов, а пользователь всё равно ждёт пустой или нерабочий первый экран. Ошибка расследования в том, что серверный ответ принимают за завершение загрузки. Браузер после первого байта ещё должен разобрать HTML, обнаружить зависимости, получить стили, выполнить синхронный код, построить дерево рендера, декодировать нужные изображения и выделить время на paint. Быстрый origin закрывает только один участок этой цепочки. Цена ошибки — оптимизировать сервер и оставить пользователя перед пустым экраном.'),
|
||||
paragraph('Здесь не нужен мифический «браузер тормозит». Нужна модель зависимостей. Одни ресурсы можно качать параллельно, но некоторые работы ждут предыдущей границы: нельзя применить внешний stylesheet до его прихода; JavaScript без <code>defer</code> может остановить разбор HTML; картинка, добавленная только после выполнения приложения, не будет обнаружена preload scanner из начального документа. Критический путь — не список всех файлов, а цепочка того, без чего выбранный полезный экран не может появиться.'),
|
||||
heading('Документ задаёт не только разметку, но и момент обнаружения'),
|
||||
paragraph('HTML приходит потоково. Пока браузер читает начальный документ, он может обнаружить <code>link</code>, <code>script</code>, <code>img</code> и начать работу с ними раньше, чем весь ответ будет получен. Поэтому важен не только размер HTML, но и место, где расположен критический URL. Если hero-изображение или основной stylesheet скрыт за JavaScript-конфигурацией, браузер узнает о нём только после новой работы; лишняя задержка возникает до реальной загрузки байтов.'),
|
||||
|
||||
@@ -510,7 +510,8 @@ const mechanismArticle = createRevision(
|
||||
heading('Fixture фиксирует порядок без настоящего broker'),
|
||||
paragraph(trainingNotice),
|
||||
codeBlock(fixtureCommandCode),
|
||||
paragraph('Положительный fixture результат означает только восемь проверок модели: v1 построена; stale read построил v2; поздний event v2 сохранил current entry; следующий read получил v2; projection не имеет editorNote; private v3 не выдана даже до event; event private v3 не находит public entry; данные остались одним учебным object. Он не доказывает confirm от очереди, atomic write source и event, eviction Redis, invalidation CDN или response браузера. Эта граница записана рядом с примером, чтобы тест не вырос в легенду о production reliability.'),
|
||||
paragraph('Положительный fixture результат означает только восемь проверок модели: v1 построена; stale read построил v2; поздний event v2 сохранил current entry; следующий read получил v2. Projection не имеет editorNote; private v3 не выдана даже до event; event private v3 не находит public entry; данные остались одним учебным object.'),
|
||||
paragraph('Fixture не доказывает confirm от очереди, atomic write source и event, eviction Redis, invalidation CDN или response браузера. Эта граница записана рядом с примером, чтобы тест не вырос в легенду о production reliability.'),
|
||||
heading('Маршрут проектирования механизма'),
|
||||
orderedList([
|
||||
'Для одного read path выписать source owner, reader scope и допустимую проекцию. Если это не один contract, не пытаться решить его одним key.',
|
||||
|
||||
@@ -544,7 +544,7 @@ const fieldArticle = createRevision(
|
||||
readingMinutes: 15,
|
||||
},
|
||||
[
|
||||
paragraph('В trace учебного request heavy-вариант пересёк allocation budget 6 и получил отметку <code>gc-boundary</code>. Рядом есть FFI и I/O boundaries, а invalid input уходит в error response. Самая дорогая ошибка здесь — назвать отметку реальной паузой, обвинить внешнюю систему без вызова или сразу менять конфигурацию runtime. Тогда исчезают и причина, и возможность безопасно откатить change.'),
|
||||
paragraph('В trace учебного request heavy-вариант пересёк allocation budget 6 и получил отметку <code>gc-boundary</code>. Рядом есть FFI и I/O boundaries, а invalid input уходит в error response. Самая дорогая ошибка здесь — назвать отметку реальной паузой, обвинить внешнюю систему без вызова или сразу менять конфигурацию runtime. Тогда исчезают и причина, и возможность безопасно откатить change. Цена ошибки — потратить время на изменение runtime без доказанного источника паузы.'),
|
||||
paragraph('Это не отчёт о production-инциденте. В нём нет реального профиля, сервера, D compiler, DRuntime, HTTP, foreign code или I/O. Есть один детерминированный in-memory request, два варианта одной обработки и error input. Его цель — собрать evidence в правильном порядке: result, stage trace, заданные units, выбранная boundary и только потом действие. Такая дисциплина полезна до того, как появятся цифры настоящего инструмента.'),
|
||||
heading('Собираем evidence до изменения runtime'),
|
||||
paragraph('Первый набор evidence небольшой: request id, нормализованный вход, success body либо error body, последовательность stages, allocation/work units модели, budget и список external boundaries. Значения, похожие на время, здесь запрещены: fixture не показывает миллисекунды, CPU или память процесса. Если соседняя система говорит о паузе, это отдельный факт с отдельным источником, а не расшифровка записи <code>gc-boundary</code>.'),
|
||||
|
||||
@@ -621,7 +621,7 @@ const fieldArticle = createRevision(
|
||||
readingMinutes: 17,
|
||||
},
|
||||
[
|
||||
paragraph('Симптом в полевом разборе конкретен: рабочая заметка уже сохранена, но публичное чтение её не подтверждает. Дорогая реакция — удалить заметку, создать вторую или «на всякий случай» отправить ещё одно событие. После этого исчезает исходная версия, два intent становятся неотличимы, а исправление может создать ещё одну projection. Сначала нужен отчёт, который переживёт вмешательство: decision id, note id, revision, intent key, observed read result и граница, на которой сделано наблюдение.'),
|
||||
paragraph('Симптом в полевом разборе конкретен: рабочая заметка уже сохранена, но публичное чтение её не подтверждает. Дорогая реакция — удалить заметку, создать вторую или «на всякий случай» отправить ещё одно событие. После этого исчезает исходная версия, два intent становятся неотличимы, а исправление может создать ещё одну projection. Сначала нужен отчёт, который переживёт вмешательство: decision id, note id, revision, intent key, observed read result и граница, на которой сделано наблюдение. Цена ошибки — потерять исходную версию и усложнить повторную доставку.'),
|
||||
paragraph('Учебная fixture даёт такой маршрут без реального production. Она хранит decision, canonical note, outbox, projection и accepted intent keys в памяти. По одному ключу <code>noteId:revision</code> она различает pending relay и duplicate delivery. Она не знает БД, HTTP, broker, retry сети, внешнего consumer, инцидента, SLO или реальных прав. Поэтому результат «public-read-ready-in-training» не означает, что текст доступен пользователю; он означает только, что локальная модель дошла до своего объявленного состояния.'),
|
||||
heading('Собираем evidence раньше, чем меняем источник'),
|
||||
paragraph('Первый вопрос: «что уже доказано?». В карточке нужны immutable identifiers, а не пересказ симптома. Для кейса это decision id, note id, revision и intent key. Затем — фактическое read observation: какой путь чтения проверяли, какой результат получили, когда и в каком scope. В учебной модели нет времени и прав доступа, поэтому эти поля не выдуманы. Реальная система добавляет только разрешённые данные, которые различают ветки: storage commit, relay receipt, projection version, filter или permission check.'),
|
||||
|
||||
@@ -206,7 +206,7 @@ const mechanismArticle = createRevision({
|
||||
excerpt: 'Разбираем порядок состояний media slot и границу утверждений: разметка, геометрия, declared load/decode и внешний observation record не являются измерением браузерной метрики.',
|
||||
readingMinutes: 13,
|
||||
}, [
|
||||
paragraph('После добавления оптимизации медиа часто возникает странный спор: один разработчик показывает unit test, другой — trace, а оба называют это «готовым LCP fix». Проблема такой подмены в том, что решение нельзя ни подтвердить, ни откатить: не ясно, что именно изменилось — markup, размеры, очередь, декодирование или условия запуска. При следующем изменении команда сравнит несравнимые результаты.'),
|
||||
paragraph('После добавления оптимизации медиа часто возникает странный спор: один разработчик показывает unit test, другой — trace, а оба называют это «готовым LCP fix». Проблема такой подмены в том, что решение нельзя ни подтвердить, ни откатить: не ясно, что именно изменилось — markup, размеры, очередь, декодирование или условия запуска. При следующем изменении команда сравнит несравнимые результаты. Цена ошибки — потратить время на «ускорение», которое не меняет путь первого экрана.'),
|
||||
paragraph('Причина в том, что у этих фактов разные владельцы. Компонент владеет описанием slot и может требовать geometry. Приложение может назначить намерение важности. Браузер выполняет загрузку, decode и layout; измерительный код наблюдает его результат при конкретных условиях. Учебный контракт ниже не притворяется браузером: он хранит declared transitions и принимает внешний record только как supplied data.'),
|
||||
heading('Пять слоёв вместо одного флага loaded'),
|
||||
table('Состояния, которые нельзя заменить одним boolean', ['Слой', 'Владелец', 'Наблюдаемый факт', 'Чего факт не доказывает'], [
|
||||
|
||||
@@ -419,7 +419,7 @@ const field = revision({
|
||||
excerpt: 'Полевой маршрут для CORS error, preflight и CSRF 403: собрать факты, разделить границы, исправить один контракт и сохранить отрицательный тест.',
|
||||
readingMinutes: 12,
|
||||
}, [
|
||||
p(`После выката frontend на новый host интерфейс не получает данные или mutation заканчивается ошибкой. Самое рискованное действие — сделать CORS глобально permissive или выключить CSRF «для проверки». Такой change может пережить инцидент и расширить доступ для origin без review. Диагностика начинается с наблюдаемого request contract, а не с флага middleware.`),
|
||||
p(`После выката frontend на новый host интерфейс не получает данные или mutation заканчивается ошибкой. Самое рискованное действие — сделать CORS глобально permissive или выключить CSRF «для проверки». Такой change расширит доступ без review. Диагностика начинается с request contract, не с флага middleware. Цена ошибки — дать origin доступ к данным или mutation.`),
|
||||
p(`Маршрут разделяет CORS error, OPTIONS failure и CSRF rejection. Локальный fixture не изображает сеть: его inputs — заданные labels, выход — решения учебного контракта. Он не сообщает response proxy, cookie delivery или access log. Для этого нужен реальный browser evidence в контролируемой среде, без переноса production cookie и secrets в заметку.`),
|
||||
h2('Соберите факты до первого исправления'),
|
||||
p(`Начните с пяти значений: полный origin страницы, URL target, method, content type и имена request headers. Потом добавьте status и response headers, которые видны в browser DevTools или на boundary proxy, а также application reason, если он не раскрывает token. Разница между <code>https://app.example.test</code> и <code>https://app.example.test:8443</code> существенна; разница между POST form и PATCH JSON тоже существенна. Лог «CORS failed» без этих полей — не доказательство причины.`),
|
||||
|
||||
@@ -538,7 +538,7 @@ const mechanism = revision({
|
||||
excerpt: 'Модель устойчивого e2e-теста: auto-wait готовит действие с элементом, readiness доказывает результат, retry классифицирует попытки, а trace остаётся evidence одного запуска.',
|
||||
readingMinutes: 13,
|
||||
}, [
|
||||
p('У e2e-теста часто один большой timeout и один текст ошибки, хотя внутри живут четыре независимых контракта. Locator должен найти ровно тот control, auto-wait должен сделать действие допустимым, продуктовый assertion должен дождаться результата, а retry должен сохранить факт повторного запуска. Когда все четыре слоя названы словом «ожидание», падение становится непонятным: инженер видит TimeoutError, но не знает, кнопка не нашлась, была перекрыта, результат не наступил или retry изменил исходные условия.'),
|
||||
p('У e2e-теста часто один большой timeout и один текст ошибки, хотя внутри живут четыре независимых контракта. Locator должен найти ровно тот control, auto-wait должен сделать действие допустимым, продуктовый assertion должен дождаться результата, а retry должен сохранить факт повторного запуска. Когда все четыре слоя названы словом «ожидание», падение становится непонятным: инженер видит TimeoutError, но не знает, кнопка не нашлась, была перекрыта, результат не наступил или retry изменил исходные условия. Цена ошибки — замедлить CI и оставить flaky-тест без причины.'),
|
||||
p('После первой удобной правки команда повышает timeout, затем добавляет retry. Часть ошибок превращается в длинные flaky, CI медленнее, а trace не связан с гипотезой. Вместо этого один тест раскладывают на четыре контракта: у каждого свой вопрос, evidence, владелец изменения и rollback.'),
|
||||
h2('Контракт действия не равен контракту результата'),
|
||||
p('В Playwright 1.37.0 auto-wait перед <code>click()</code> проверяет набор actionability conditions. Для click это attached, visible, stable, receives events и enabled. Этот механизм решает узкую задачу: не отправить действие в элемент, который исчез, невидим, движется, перекрыт или disabled. Он не может узнать смысл вашей операции. Кнопка может быть полностью ready для click, но сервер вернёт отказ, клиент покажет validation error или асинхронное подтверждение не появится.'),
|
||||
|
||||
@@ -821,7 +821,8 @@ const field = revision({
|
||||
].join('\n')),
|
||||
p('Fixture добавляет более жёсткие границы, чем happy path. Extra key и missing key не проходят exact contract. Forged authorization и расширенный scope не совпадают с fixed request proof. Sparse array и cyclic value не становятся «пустым списком», а закрываются без исключения. Отдельные cases проверяют requester access, authorization expiry и wrong egress scope. Это не антифрод и не DLP; это проверка, что сам учебный gate не переходит от отсутствующих фактов к неявному разрешению.'),
|
||||
h2('Как оформить human review без ложного юридического вывода'),
|
||||
p('Вопрос владельцу должен быть конкретнее, чем «можно ли использовать AI?». Например: «для record class X, surface Y и destination Z: какое allowability rule действует, каким immutable document подтверждается retention/training condition, кто является authority, какой requester и expiry покрыты?» Такой вопрос не утверждает, что DPA сам по себе разрешает передачу или что UI setting гарантирует место обработки. Он просит evidence, по которому организация вправе сделать собственный вывод.'),
|
||||
p('Вопрос владельцу должен быть конкретнее, чем «можно ли использовать AI?». Например: «для record class X, surface Y и destination Z: какое allowability rule действует, каким immutable document подтверждается retention/training condition?»'),
|
||||
p('В том же вопросе нужно назвать authority, requester и expiry. Такая формулировка не утверждает, что DPA сам по себе разрешает передачу или что UI setting гарантирует место обработки. Она просит evidence, по которому организация вправе сделать собственный вывод.'),
|
||||
p('Полезно разделить роли. Data owner подтверждает class и преобразование. Service or legal owner сопоставляет policy/contract с выбранной surface. Network owner подтверждает path и egress control. Security or privacy owner проверяет access and authorization process. Один человек может совмещать роли в небольшой компании, но в review всё равно стоит записать, какой вопрос он закрыл. Это снижает риск «одобрения вообще» и позволяет вернуть только спорный слой на доработку.'),
|
||||
h2('Что дают источники, а чего они не дают'),
|
||||
p('GitHub Docs на commit 30 апреля 2025 говорит, что в конкретной Copilot Chat surface prompt может обрабатываться вместе с context, а Bing search при включении отправляет сформированный query в Bing Search API. Это поддерживает постановку вопроса о расширенном context и отдельной внешней границе. Документ SKU isolation показывает пример endpoint-level firewall control. Он не описывает class конкретного лога, не подтверждает retention или training terms организации и не назначает человека, который может выдать approval.'),
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import archive from '../data/articles.json' with { type: 'json' };
|
||||
const archiveBySlug = new Map(archive.map((article) => [article.slug, article]));
|
||||
|
||||
function p(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function h2(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
function codeBlock(lines) {
|
||||
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<table><caption>' + headers[0] + ': рабочая матрица проверки</caption>' + head + body + '</table>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function sources(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.label + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function legacyBody(article, limit = 6600, omitPatterns = []) {
|
||||
const withoutSources = (article.contentHtml || '')
|
||||
.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/i, '')
|
||||
.replace(/<figure>[\s\S]*?<\/figure>/gi, '')
|
||||
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
|
||||
.replace(/<\/?div[^>]*>/gi, '');
|
||||
const blocks = withoutSources.match(/<(?:p|h2|h3|pre|ul|ol)\b[\s\S]*?<\/(?:p|h2|h3|pre|ul|ol)>/gi) || [];
|
||||
const result = [];
|
||||
let length = 0;
|
||||
for (const block of blocks) {
|
||||
if (omitPatterns.some((pattern) => pattern.test(plainText(block)))) continue;
|
||||
const blockLength = plainText(block).length;
|
||||
if (result.length > 0 && length + blockLength > limit) break;
|
||||
result.push(block);
|
||||
length += blockLength;
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
const cases = {
|
||||
'использование-jquery-в-webpack': {
|
||||
title: 'JavaScript. jQuery в Webpack: глобальная зависимость без скрытого порядка',
|
||||
excerpt: 'Как подключить jQuery к Webpack-проекту, когда часть кода ждёт window.jQuery, а новые модули используют import.',
|
||||
cover: '/assets/illustrations/jquery-webpack.svg',
|
||||
problem: 'Старая страница видит `$`, а новый модуль получает пустое значение. Цена ошибки — либо дублированная библиотека в каждом бандле, либо плагины, которые работают только из-за случайного порядка подключения.',
|
||||
context: 'Оригинальный материал правильно начинает с установки пакета и `ProvidePlugin`. В редактуре важно разделить две задачи: дать старому коду совместимое глобальное имя и оставить импорт явным в новых модулях. Если смешать их в одном правиле сборки, после смены entry-файла ошибка проявится только на части страниц.',
|
||||
mechanism: ['Webpack строит граф модулей из import и require. Глобальная переменная не входит в этот граф как обычная зависимость, поэтому старый плагин может работать только при дополнительном правиле ProvidePlugin или явной записи в window.', 'ProvidePlugin подставляет импорт в местах, где встречается идентификатор. Это не делает jQuery глобальной для любого скрипта, загруженного отдельно через HTML. Для такого скрипта нужен один согласованный entry и одна точка экспорта.', 'Если библиотека уже приходит с CDN, её следует объявить external и проверить, что глобальное имя появляется раньше потребителя. Две независимые копии jQuery дают разные объекты и ломают плагины, которые сравнивают `$.fn` или регистрируют обработчики.'],
|
||||
code: ['const webpack = require(\'webpack\');', '', 'module.exports = {', ' entry: {', ' legacy: \'./src/legacy-entry.js\',', ' modern: \'./src/modern-entry.js\'', ' },', ' plugins: [', ' new webpack.ProvidePlugin({', ' $: \'jquery\',', ' jQuery: \'jquery\'', ' })', ' ]', '};', '', '// В новом модуле зависимость остаётся видимой.', "import $ from 'jquery';", 'export function mount() {', ' return $.fn && $.fn.jquery;', '}'],
|
||||
rows: [['Слой', 'Что проверяем', 'Типичная ошибка'], ['Новый модуль', 'Есть `import $ from \'jquery\'`', 'Зависимость спрятана в window'], ['Старый плагин', 'Потребитель получает тот же объект', 'Созданы две копии jQuery'], ['Entry', 'Библиотека загружена до потребителя', 'Порядок зависит от HTML'], ['CDN', 'external и глобальное имя согласованы', 'Бандл ожидает модуль, а получает URL']],
|
||||
steps: ['Определить, какие файлы используют import, а какие обращаются к `$` или `window.jQuery`.', 'Оставить один способ доставки jQuery для каждого entry: пакет или external, но не случайную смесь.', 'Добавить ProvidePlugin только для legacy-кода и проверить итоговый граф сборки.', 'Запустить страницу с реальным старым плагином и убедиться, что объект jQuery один.', 'После миграции каждого потребителя удалить лишнее глобальное правило и зафиксировать это в тесте сборки.'],
|
||||
limits: ['ProvidePlugin не исправляет порядок независимых `<script>`-тегов в HTML.', 'Версия jQuery и версия плагина должны быть частью контракта: зелёная сборка не доказывает совместимость поведения.', 'Пример показывает конфигурацию Webpack, а не измерение размера конкретного проекта.'],
|
||||
omitPatterns: [/В таком случае только в модуле/i],
|
||||
sources: [{ label: 'Webpack: ProvidePlugin', url: 'https://webpack.js.org/plugins/provide-plugin/', note: 'описывает автоматическую подстановку импорта' }, { label: 'Webpack: externals', url: 'https://webpack.js.org/configuration/externals/', note: 'фиксирует правила для внешних зависимостей' }, { label: 'jQuery API', url: 'https://api.jquery.com/', note: 'проверяет публичный объект и методы библиотеки' }],
|
||||
},
|
||||
'bitrix-api-add-foto-editor': {
|
||||
title: 'Bitrix API. Встроенный редактор изображений: UI не заменяет серверный контракт',
|
||||
excerpt: 'Как подключить FileInput в Bitrix и проверить, что выбранный файл действительно привязан к сущности после сохранения.',
|
||||
cover: '/assets/illustrations/bitrix-photo-editor.svg',
|
||||
problem: 'Редактор показывает выбранную картинку, но после отправки формы поле остаётся пустым. Цена ошибки — оператор считает файл сохранённым и узнаёт о потере только на опубликованной странице.',
|
||||
context: 'В исходной заметке полезен сам вызов `Bitrix\\Main\\UI\\FileInput`. Теперь рядом с ним нужен контроль жизненного цикла: UI создаёт данные для формы, сервер проверяет вход, а сущность хранит итоговую связь. Каждый слой должен иметь собственную проверку.',
|
||||
mechanism: ['Компонент FileInput умеет показать диалог и собрать поля формы, но не знает бизнес-правило, к какой записи относится файл. Идентификатор сущности и имя поля должны приходить из серверного контекста, а не из DOM.', 'После отправки сервер проверяет права, тип, размер и число файлов. Одного расширения недостаточно: MIME и содержимое могут расходиться. Для замены картинки нужно отдельно определить, удаляется ли старый файл и можно ли восстановить его при ошибке.', 'Результат сохранения проверяется чтением сущности. Наличие `ID` файла в ответе формы ещё не доказывает, что связь записана в нужное свойство инфоблока.'],
|
||||
code: ['<?php', '$field = \\Bitrix\\Main\\UI\\FileInput::createInstance([', ' "name" => "picture",', ' "upload" => true,', ' "allowUpload" => "I",', ' "maxCount" => 1,', ' "delete" => true,', ']);', '', 'echo $field->show($elementId);', '', '// После POST проверяем результат API, а не только состояние виджета.', '$fileId = (int)($request->getPost("picture") ?? 0);', 'if ($fileId <= 0) {', ' throw new RuntimeException("Файл не выбран");', '}'],
|
||||
rows: [['Участок', 'Вопрос', 'Проверка'], ['Форма', 'Поля отправились?', 'POST содержит ожидаемое имя и ID'], ['Права', 'Кто меняет файл?', 'Пользователь может редактировать сущность'], ['Файл', 'Что реально загружено?', 'Размер, MIME, расширение и содержимое'], ['Связь', 'Куда записан ID?', 'Повторное чтение свойства элемента']],
|
||||
steps: ['Назвать сущность, поле и владельца операции до подключения редактора.', 'Показать FileInput с ограничением количества и разрешённых типов.', 'Проверить POST на сервере и отклонить неожиданные поля или пустой ID.', 'Сохранить связь через API Bitrix и обработать `LAST_ERROR` или исключение.', 'Повторно прочитать свойство элемента и проверить опубликованный сценарий.', 'Для замены файла отдельно зафиксировать поведение старого файла и откат при ошибке.'],
|
||||
limits: ['Названия параметров и доступные режимы зависят от версии Bitrix; перед переносом сверяйте API своего проекта.', 'Встроенный редактор не заменяет антивирусную и серверную проверку загрузки.', 'Пример не содержит реального ID или данных пользователя.'],
|
||||
omitPatterns: [/^Параметры:/i],
|
||||
sources: [{ label: 'Bitrix: FileInput', url: 'https://dev.1c-bitrix.ru/api_help/main/ui/fileinput/index.php', note: 'показывает API компонента загрузки' }, { label: 'Bitrix: CIBlockElement', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/index.php', note: 'описывает работу с элементом инфоблока' }, { label: 'OWASP: File Upload Cheat Sheet', url: 'https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html', note: 'даёт независимый список серверных проверок' }],
|
||||
},
|
||||
'о-tilix-и-d-интервью-с-геральдом-нанном': {
|
||||
title: 'Tilix и D: что инженер проверяет в инструменте, прежде чем выбрать его',
|
||||
excerpt: 'Редакторская версия интервью о Tilix и D: как отделить личный сценарий терминала от проверяемых свойств инструмента и языка.',
|
||||
cover: '/assets/illustrations/tilix-cover.svg',
|
||||
problem: 'Интервью о любимом инструменте легко превращается в список восторгов. Цена такой подачи — читатель переносит чужой рабочий сценарий на свою среду и получает неудобный интерфейс или неподходящий стек.',
|
||||
context: 'Сохраняю в материале разговор с Геральдом Нанном и технические детали Tilix, но выношу из него критерии, которые можно проверить самостоятельно. Интервью — источник опыта автора проекта, а не доказательство, что инструмент лучше любого другого терминала.',
|
||||
mechanism: ['Tilix решает конкретную задачу: разделяет терминальные сессии в одном окне и сохраняет рабочий контекст. Ценность появляется там, где человеку действительно нужно видеть несколько процессов рядом; при одной короткой команде тайлинг только добавляет интерфейс.', 'D в таком проекте важен не как рекламная метка, а как набор свойств реализации: нативный бинарник, доступ к GTK через библиотеку и автоматическое управление памятью. Эти свойства нужно сопоставлять с размером приложения, зависимостями платформы и способом доставки.', 'Любой отзыв из интервью следует превратить в проверку: открыть исходный код, собрать минимальную версию, проверить горячие клавиши и замерить время запуска в своей среде. Так личная история становится полезной инженерной гипотезой.'],
|
||||
code: ['# Проверка собственного сценария: два процесса и один лог', 'tilix --session ~/.config/tilix/sessions/debug.json', '', '# В проекте D сначала фиксируем версию компилятора.', 'dmd --version', 'dub --version', '', '# Затем собираем без изменения пользовательской системы.', 'dub build --build=debug'],
|
||||
rows: [['Критерий', 'Что видно в интервью', 'Что проверить у себя'], ['Рабочее окно', 'Несколько терминалов в одном layout', 'Сколько панелей реально нужно ежедневно'], ['Интеграция', 'GTK и desktop-сценарий', 'Версия GTK и поведение на целевой ОС'], ['Язык', 'D используется для приложения', 'Версия DMD/LDC, время сборки и зависимости'], ['Сопровождение', 'Открытый проект и сообщество', 'Релизы, issue tracker и способ собрать пакет']],
|
||||
steps: ['Описать один повторяемый сценарий: какие команды запускаются и что нужно видеть одновременно.', 'Отделить свойства Tilix от свойств конкретной конфигурации автора интервью.', 'Проверить версию приложения, GTK и компилятора в своей системе.', 'Собрать маленький D-проект и измерить путь от исходников до запуска.', 'Сравнить результат с привычным терминалом по времени и количеству ручных действий.', 'Оставить инструмент, если он сокращает конкретную работу; не делать вывод по числу функций.'],
|
||||
limits: ['Интервью датировано 2017 годом. Состояние репозитория, пакетов и платформ с тех пор менялось.', 'Опыт одного разработчика не является benchmark и не заменяет тестирование на целевой ОС.', 'Сборка с `dub` и `dmd` требует конкретных версий и системных библиотек.'],
|
||||
omitPatterns: [/^Несмотря на то, что Tilix/i],
|
||||
sources: [{ label: 'Tilix: исходный код', url: 'https://github.com/gnunn1/tilix', note: 'проверяет актуальную структуру и инструкции сборки' }, { label: 'D language: спецификация', url: 'https://dlang.org/spec/', note: 'фиксирует правила языка, а не личные оценки инструмента' }, { label: 'D: automatic memory management', url: 'https://dlang.org/spec/garbage.html', note: 'помогает проверить тезисы об управлении памятью' }],
|
||||
},
|
||||
'bitrix-api-функция-для-генерации-кода-элемент': {
|
||||
title: 'Bitrix API. Символьный код элемента: транслитерация — только первый шаг',
|
||||
excerpt: 'Как получить стабильный CODE из названия в Bitrix и не сломать URL повтором, разделом или массовым обновлением.',
|
||||
cover: '/assets/illustrations/bitrix-translit-api.svg',
|
||||
problem: 'Транслитерация возвращает красивую строку, но два товара получают одинаковый CODE. Цена ошибки — дубли адресов, неверная карточка и необходимость чинить старые ссылки.',
|
||||
context: 'Исходная функция `CUtil::translit` остаётся хорошей точкой входа. Редактура добавляет три обязательных слоя: нормализацию результата, проверку уникальности и правило поведения при смене уже опубликованного кода.',
|
||||
mechanism: ['Транслитератор решает только преобразование символов. Он не знает инфоблок, язык витрины и уже занятые адреса. Поэтому CODE нужно считать частью данных элемента, а не производным текстовым украшением.', 'Уникальность проверяется в том же контексте, где код будет использоваться. Для каталога это обычно инфоблок и, возможно, раздел. Если URL глобальный, суффикс нужно добавлять до записи, а не после ошибки маршрута.', 'Смена CODE — изменение публичного адреса. Нужны редирект или таблица старых ссылок, а также проверка всех мест, где код сохранён как внешний идентификатор.'],
|
||||
code: ['function makeElementCode(string $name, int $id = 0): string', '{', ' $base = CUtil::translit($name, "ru", [', ' "max_len" => 80,', ' "change_case" => "L",', ' "replace_space" => "-",', ' "replace_other" => "-",', ' "delete_repeat_replace" => true,', ' ]);', ' $base = trim(preg_replace("/-+", "-", $base), "-");', ' $code = $base ?: "item";', ' $suffix = 1;', ' while (codeExists($code, $id)) {', ' $suffix++;', ' $code = $base . "-" . $suffix;', ' }', ' return $code;', '}'],
|
||||
rows: [['Шаг', 'Вход', 'Результат'], ['Транслитерация', 'Название на русском', 'Кандидат CODE'], ['Нормализация', 'Пробелы и повторные дефисы', 'Одна каноническая строка'], ['Уникальность', 'Кандидат и ID текущего элемента', 'Свободный код или суффикс'], ['Публикация', 'Старый и новый URL', 'Правило редиректа или ссылки']],
|
||||
steps: ['Выбрать область уникальности: весь инфоблок, раздел или отдельная витрина.', 'Сгенерировать кандидат через `CUtil::translit` и нормализовать дефисы.', 'Проверить существующий CODE, исключив текущий ID при редактировании.', 'Сохранить код только после успешной проверки обязательных данных.', 'Для опубликованного элемента сохранить старый URL и проверить переход.', 'Добавить тесты для пустого имени, Unicode, повторов и одинаковых названий.'],
|
||||
limits: ['Правило транслитерации и параметры `CUtil` зависят от версии Bitrix.', 'Уникальность в базе должна быть защищена от гонки при параллельном создании.', 'Суффикс исправляет конфликт, но не решает вопрос понятного URL для бизнеса.'],
|
||||
sources: [{ label: 'Bitrix: CUtil::translit', url: 'https://dev.1c-bitrix.ru/api_help/main/functions/cutil/translit.php', note: 'описывает параметры транслитерации' }, { label: 'Bitrix: CIBlockElement', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/index.php', note: 'показывает контекст записи элемента' }, { label: 'Bitrix: URL rewrite', url: 'https://dev.1c-bitrix.ru/api_help/main/general/urlrewrite.php', note: 'помогает проверить последствия смены адреса' }],
|
||||
},
|
||||
'bitrix-api-создание-добавление-торгового-пре': {
|
||||
title: 'Bitrix API. Торговое предложение: создать запись и довести её до каталога',
|
||||
excerpt: 'Почему одного CIBlockElement::Add недостаточно для торгового предложения и как проверить связь товара, свойств, цены и остатка.',
|
||||
cover: '/assets/illustrations/bitrix-offer-api.svg',
|
||||
problem: 'Предложение создаётся без ошибки, но не появляется в карточке товара. Цена ошибки — администратор видит ID записи, а покупатель не видит вариант, цену или остаток.',
|
||||
context: 'В старом рецепте важен вызов API создания. Для читателя нужно явно разделить элемент инфоблока и товарную модель: предложение — это запись, связанная с товаром, а цена и остаток живут в дополнительных сущностях каталога.',
|
||||
mechanism: ['`CIBlockElement::Add` возвращает ID элемента, но не гарантирует, что у него есть обязательные свойства связи. Сначала сохраняют минимальный набор полей и проверяют ошибку, затем создают или обновляют товарную часть.', 'Связь с родительским товаром должна быть однозначной. Если свойство XML_ID, SKU или ID заполнено неверно, каталог может показать пустой результат, хотя запись физически есть.', 'После записи нужна выборка тем же способом, которым читает каталог. Это ловит ошибки активности, дат, прав, раздела и кеша без догадки о том, какая кнопка «обновить» поможет.'],
|
||||
code: ['<?php', '$element = new CIBlockElement();', '$offerId = $element->Add([', ' "IBLOCK_ID" => $offerIblockId,', ' "NAME" => "Кофе 250 г",', ' "ACTIVE" => "Y",', ' "PROPERTY_VALUES" => [', ' "CML2_LINK" => $productId,', ' "ARTNUMBER" => "COFFEE-250",', ' ],', ']);', '', 'if (!$offerId) {', ' throw new RuntimeException($element->LAST_ERROR);', '}', '', '// Затем отдельно проверяем цену, остаток и связь с productId.'],
|
||||
rows: [['Слой', 'Что сохраняется', 'Чем подтверждается'], ['Инфоблок', 'ID, имя, активность, свойства', 'Повторная выборка элемента'], ['Связь', 'ID родительского товара', 'Свойство связи совпадает с товаром'], ['Цена', 'Тип цены и значение', 'Запрос каталога возвращает цену'], ['Остаток', 'Количество и доступность', 'Карточка показывает ожидаемый статус']],
|
||||
steps: ['Назвать ID инфоблока предложения и ID родительского товара.', 'Сохранить элемент через API и сразу обработать `LAST_ERROR`.', 'Проверить обязательное свойство связи и уникальный артикул.', 'Создать или обновить товарные параметры отдельным шагом.', 'Прочитать предложение через тот же компонент или запрос, что использует витрина.', 'Только после проверки данных обновить кеш и индекс по правилам проекта.'],
|
||||
limits: ['API каталога менялся между версиями Bitrix; исторический код нельзя переносить без сверки версии.', 'ID элемента не подтверждает наличие цены, остатка или видимость в компоненте.', 'Массовую загрузку нужно защищать от повторного запуска и гонки уникальности.'],
|
||||
sources: [{ label: 'Bitrix: CIBlockElement::Add', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php', note: 'описывает создание элемента и обработку ошибки' }, { label: 'Bitrix: каталог', url: 'https://dev.1c-bitrix.ru/api_help/catalog/', note: 'разделяет элемент, товар и цены' }, { label: 'Bitrix: торговые предложения', url: 'https://dev.1c-bitrix.ru/api_help/catalog/classes/ccatalogsku/index.php', note: 'помогает проверить связь SKU с товаром' }],
|
||||
},
|
||||
'firefox-увеличение-ожидания-загрузки-timeout-стр': {
|
||||
title: 'Firefox. Таймаут страницы: увеличиваем ожидание только после проверки причины',
|
||||
excerpt: 'Как отличить медленный ответ сервера от сетевого сбоя и изменить параметр Firefox так, чтобы не скрыть проблему.',
|
||||
cover: '/assets/illustrations/firefox-timeout.svg',
|
||||
problem: 'Firefox прекращает загрузку долгого запроса, и первая реакция — увеличить timeout. Цена ошибки — браузер дольше держит пользователя на сломанном соединении, а серверная причина остаётся без диагностики.',
|
||||
context: 'В исходной инструкции полезно само указание на `about:config`. Редактура добавляет порядок: сначала DevTools и другой клиент, затем проверка прокси и сервера, и только потом временная настройка с понятным сроком возврата.',
|
||||
mechanism: ['Таймаут браузера — один из нескольких пределов. Запрос могут завершить сервер, reverse proxy, балансировщик или сеть. Если изменить только Firefox, он не продлит соединение, которое уже закрывает другой слой.', 'В Network-панели нужно посмотреть, на каком этапе проходит время: DNS, соединение, ожидание первого байта или загрузка тела. Длинный TTFB указывает на серверную работу; обрыв соединения — на другой класс причины.', 'Параметр в `about:config` действует локально и может меняться между версиями. Это диагностическая мера, а не настройка приложения для всех пользователей.'],
|
||||
code: ['# 1. Проверить тот же URL без браузерного UI.', 'curl -v --max-time 60 https://example.test/report', '', '# 2. В Firefox открыть about:config и найти параметр', '# network.http.response.timeout', '# Сохранить старое значение и менять его только в профиле диагностики.', '', '# 3. После исправления вернуть параметр и повторить Network trace.'],
|
||||
rows: [['Наблюдение', 'Где искать причину', 'Следующий шаг'], ['Нет ответа до первого байта', 'Приложение, БД, внешний API', 'Снять серверный trace и время этапов'], ['Соединение закрыто прокси', 'Reverse proxy или балансировщик', 'Сверить таймауты всех уровней'], ['Только Firefox падает', 'Профиль, расширение, локальная настройка', 'Повторить в чистом профиле'], ['curl и браузер одинаково медленны', 'Сервер или сеть', 'Не менять браузерный параметр первым']],
|
||||
steps: ['Записать URL, время начала запроса и наблюдаемый этап в DevTools.', 'Повторить запрос через `curl` с ограниченным таймаутом.', 'Сравнить таймауты браузера, прокси, сервера и внешних зависимостей.', 'Изменить параметр только в отдельном Firefox-профиле и сохранить старое значение.', 'После исправления вернуть параметр и проверить, что причина устранена, а не замаскирована.'],
|
||||
limits: ['Названия и смысл внутренних параметров Firefox могут меняться; сверяйте версию браузера.', 'Увеличенный timeout не делает операцию быстрее и не заменяет отмену запроса.', 'Не просите пользователя менять `about:config` как постоянное решение серверной проблемы.'],
|
||||
sources: [{ label: 'Mozilla Support: Firefox не загружает сайты', url: 'https://support.mozilla.org/en-US/kb/firefox-cant-load-websites-other-browsers-can', note: 'даёт порядок проверки соединения и расширений' }, { label: 'MDN: Network requests', url: 'https://developer.mozilla.org/en-US/docs/Tools/Network_Monitor', note: 'объясняет поля сетевого запроса в DevTools' }, { label: 'Firefox source code', url: 'https://searchfox.org/mozilla-central/search?q=network.http.response.timeout', note: 'помогает сверить исторический параметр с версией' }],
|
||||
},
|
||||
'ошибка-php-ssl-certificate-error-unable-to-get-local-issuer-certificate': {
|
||||
title: 'PHP. unable to get local issuer certificate: восстановить цепочку доверия, не отключая TLS',
|
||||
excerpt: 'Как диагностировать ошибку CA bundle в cURL и PHP и настроить доверенный корень без `verify_peer=false`.',
|
||||
cover: '/assets/illustrations/php-ssl-cacert.svg',
|
||||
problem: 'PHP-клиент не принимает сертификат, хотя сайт открывается в браузере. Цена ошибки — команда отключает проверку TLS и превращает временную проблему с CA bundle в постоянную уязвимость.',
|
||||
context: 'Старый рецепт часто сводится к скачиванию `cacert.pem`. В качественной версии важнее понять цепочку: какой клиент выполняет запрос, где он ищет доверенные корни, какой сертификат прислал сервер и совпадает ли окружение CLI с окружением FPM.',
|
||||
mechanism: ['Клиент проверяет не только имя хоста. Он строит цепочку от сертификата сервера до доверенного корня и сверяет срок, имя и подпись. Если промежуточный сертификат не пришёл или локальное хранилище устарело, появляется `unable to get local issuer certificate`.', 'Браузер и PHP могут использовать разные хранилища. Поэтому «в браузере зелёный замок» не доказывает, что CLI-cURL видит тот же CA bundle.', 'Исправление должно менять путь к актуальному bundle или хранилище ОС. Отключение `CURLOPT_SSL_VERIFYPEER` и `CURLOPT_SSL_VERIFYHOST` убирает доказательство подлинности и не является диагностикой.'],
|
||||
code: ['<?php', '$ch = curl_init("https://example.test/health");', 'curl_setopt_array($ch, [', ' CURLOPT_RETURNTRANSFER => true,', ' CURLOPT_CAINFO => __DIR__ . "/certs/cacert.pem",', ' CURLOPT_SSL_VERIFYPEER => true,', ' CURLOPT_SSL_VERIFYHOST => 2,', ']);', '$body = curl_exec($ch);', 'if ($body === false) {', ' throw new RuntimeException(curl_error($ch));', '}', 'curl_close($ch);'],
|
||||
rows: [['Проверка', 'Что смотрим', 'Почему это важно'], ['Сервер', 'Цепочка сертификатов и имя', 'Клиент должен получить промежуточный сертификат'], ['PHP', 'Версия и cURL/OpenSSL', 'CLI и FPM могут иметь разные настройки'], ['CA bundle', 'Путь и дата обновления', 'Старый корень даёт ложную ошибку'], ['TLS-режим', 'verify peer/host включены', 'Отключение проверки нельзя считать исправлением']],
|
||||
steps: ['Получить ошибку с включённым TLS verify и сохранить точный текст.', 'Проверить цепочку сервера через `openssl s_client` или доверенный TLS checker.', 'Узнать CA bundle для CLI и FPM, не смешивая окружения.', 'Обновить доверенное хранилище из официального источника и задать путь через конфигурацию.', 'Повторить запрос с `CURLOPT_SSL_VERIFYPEER=true` и `CURLOPT_SSL_VERIFYHOST=2`.', 'Добавить проверку в deployment или health-check, чтобы bundle не устарел незаметно.'],
|
||||
limits: ['Не скачивайте CA bundle по тому же неподтверждённому соединению, которое пытаетесь проверить.', 'Сертификат с самоподписанным корнем требует отдельного доверенного процесса, а не отключения проверки.', 'Пути `php.ini` и расширения различаются между CLI, FPM и контейнером.'],
|
||||
sources: [{ label: 'PHP: cURL configuration', url: 'https://www.php.net/manual/en/curl.configuration.php', note: 'описывает `curl.cainfo` и настройки расширения' }, { label: 'curl: SSL certificate verification', url: 'https://curl.se/docs/sslcerts.html', note: 'объясняет CA store и цепочку доверия' }, { label: 'OpenSSL: verify', url: 'https://docs.openssl.org/3.0/man1/openssl-verification-options/', note: 'помогает проверить сертификат вне PHP' }],
|
||||
},
|
||||
'dconf-2017-под-капотом-мусорщика-ди-дмитрий-ол': {
|
||||
title: 'DConf 2017. Сборщик мусора D: наблюдаем паузу, а не обвиняем GC вслепую',
|
||||
excerpt: 'Редакторская версия заметки о GC в D: как отличить давление на память, паузу сборки и лишние аллокации.',
|
||||
cover: '/assets/illustrations/dconf-gc-cover.svg',
|
||||
problem: 'Программа на D начинает отвечать рывками, и вся причина объявляется «медленным сборщиком». Цена ошибки — отключить полезную автоматическую память и получить утечки или ещё более дорогие ручные освобождения.',
|
||||
context: 'В исходной заметке сохраняю материалы DConf и объяснение устройства GC. Сокращаю повторения и добавляю практическую рамку: сначала измеряем аллокации и размер heap, затем смотрим профиль паузы, и только после этого выбираем настройку.',
|
||||
mechanism: ['GC в D освобождает недостижимые объекты, но стоимость возникает не только в момент полной коллекции. Лишние временные объекты увеличивают объём работы, а долгоживущие ссылки удерживают память и меняют частоту циклов.', 'Пауза зависит от реализации runtime, размера набора объектов и режима приложения. Один замер в debug-сборке не говорит, что такой же результат будет у release-бинарника с другой нагрузкой.', 'Ручной `GC.collect` может быть полезен на границе batch-операции, но вызов в каждом цикле обычно только переносит работу в горячее место. Сначала нужно увидеть, где создаётся давление.'],
|
||||
code: ['import core.memory : GC;', 'import std.stdio : writeln;', '', 'void processBatch(const int[] values) {', ' auto before = GC.stats();', ' foreach (value; values) {', ' // Не создаём временную строку на каждой итерации без причины.', ' auto squared = value * value;', ' writeln(squared);', ' }', ' auto after = GC.stats();', ' writeln("allocated: ", after.usedSize - before.usedSize);', '}', '', '// GC.collect() — отдельный эксперимент на границе batch,', '// а не универсальный вызов в каждой функции.'],
|
||||
rows: [['Сигнал', 'Что измерить', 'Не делать первым'], ['Редкие длинные паузы', 'Длительность и частоту GC', 'Сразу отключать GC'], ['Рост heap', 'Кто удерживает долгоживущие ссылки', 'Увеличивать лимит без профиля'], ['Много мелких объектов', 'Аллокации в горячем цикле', 'Оптимизировать только сборщик'], ['Batch-задача', 'Пауза на границе операции', 'Вызывать collect на каждой итерации']],
|
||||
steps: ['Собрать release-профиль на повторяемом наборе данных.', 'Разделить время приложения и время GC, а также размер занятой памяти.', 'Найти горячие места, где создаются временные объекты.', 'Повторить замер после уменьшения аллокаций, не меняя сразу режим GC.', 'Проверить `GC.collect` только на явной границе batch и сравнить паузу.', 'Оставить настройку рядом с числом измерения и условием, при котором она нужна.'],
|
||||
limits: ['Детали реализации и профили GC зависят от версии runtime D.', 'Слайды конференции объясняют модель, но не заменяют профиль вашей программы.', 'В примере нет реального production-нагрузочного замера.'],
|
||||
sources: [{ label: 'D language: garbage collection', url: 'https://dlang.org/spec/garbage.html', note: 'описывает модель автоматической памяти D' }, { label: 'D: core.memory', url: 'https://dlang.org/library/core/memory.html', note: 'показывает API runtime для наблюдения и управления GC' }, { label: 'DConf', url: 'https://dconf.org/2017/', note: 'сохраняет исторический контекст конференции 2017 года' }],
|
||||
},
|
||||
'пишем-аналог-функции-php-preg_match_all-на-языке-прог': {
|
||||
title: 'D и регулярные выражения: повторяем preg_match_all, не теряя смысл совпадений',
|
||||
excerpt: 'Как построить аналог поиска всех совпадений в D и проверить различия между PHP-массивом и диапазоном matchAll.',
|
||||
cover: '/assets/illustrations/preg-match-all-d.svg',
|
||||
problem: 'Перенос регулярного выражения из PHP в D компилируется, но возвращает другой набор групп. Цена ошибки — неверно разобранный текст, который выглядит корректным до первого вложенного или пустого совпадения.',
|
||||
context: 'Исходная статья полезна как портирование знакомой функции. Здесь оно превращается в контракт: что считается совпадением, какие группы нужны, как обрабатывается отсутствие результата и где заканчивается ответственность regex.',
|
||||
mechanism: ['`preg_match_all` возвращает структуру, зависящую от режима и групп. В D `std.regex` даёт диапазон совпадений, который удобно обходить, но порядок доступа к группам нужно зафиксировать отдельно.', 'Регулярное выражение не валидирует весь бизнес-формат автоматически. Если нужен полный разбор строки, проверяем якоря `^` и `$`; если нужно найти части внутри текста, не добавляем их случайно.', 'Пустое совпадение опасно для цикла: выражение может не продвинуть позицию так, как ожидает вызывающий код. Тест на пустую строку и соседние совпадения обязателен.'],
|
||||
code: ['import std.regex : regex, matchAll;', 'import std.stdio : writeln;', '', 'auto pattern = regex(`(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>[^;]+)`);', 'auto input = "mode=fast; retries=3";', '', 'foreach (m; matchAll(input, pattern)) {', ' writeln(m["key"], " => ", m["value"]);', '}', '', '// Ожидаемый вывод:', '// mode => fast', '// retries => 3'],
|
||||
rows: [['Вопрос', 'PHP', 'D'], ['Все совпадения', '`preg_match_all`', '`matchAll` как диапазон'], ['Группа', 'Индекс или имя', 'Индекс или именованная группа'], ['Нет результата', 'Пустой массив/false', 'Пустой диапазон'], ['Проверка формата', 'Отдельная логика', 'Отдельная логика']],
|
||||
steps: ['Записать пример входа и ожидаемые совпадения до переноса.', 'Перенести шаблон и отдельно проверить экранирование строкового литерала D.', 'Выбрать доступ к группам по имени или индексу и закрепить его тестом.', 'Проверить пустой вход, две соседние записи и значение с разделителем.', 'Сравнить количество и содержимое совпадений с эталоном PHP.', 'Если формат стал сложнее regex, передать результат полноценному парсеру.'],
|
||||
limits: ['Совместимость синтаксиса PCRE и regex D не абсолютна; сверяйте документацию конкретной версии.', 'Регулярное выражение не заменяет проверку размера, кодировки и семантики значения.', 'Пример разбирает учебную строку и не читает пользовательский файл.'],
|
||||
omitPatterns: [
|
||||
/^Описание функции:/i,
|
||||
/preg_match_all\s*\(/i,
|
||||
/^class PReg/i,
|
||||
/^typePatternOrder/i,
|
||||
/^auto stdMatches/i,
|
||||
/^while \(/i,
|
||||
/^assert\(/i,
|
||||
/^module preg/i,
|
||||
/^import std\.string/i,
|
||||
/^match\.position/i,
|
||||
/^alias type/i,
|
||||
],
|
||||
sources: [{ label: 'D: std.regex', url: 'https://dlang.org/phobos/std_regex.html', note: 'описывает regex, match и диапазоны совпадений' }, { label: 'PHP: preg_match_all', url: 'https://www.php.net/manual/en/function.preg-match-all.php', note: 'фиксирует исходный контракт PHP' }, { label: 'D language: strings', url: 'https://dlang.org/spec/arrays.html#strings', note: 'помогает сверить работу со строками и срезами' }],
|
||||
},
|
||||
'новый-движок-ctfe': {
|
||||
title: 'D CTFE: вычислить константу на этапе компиляции и проверить границу применения',
|
||||
excerpt: 'Что меняет новый движок CTFE в D и как отличить полезное вычисление на этапе компиляции от скрытого усложнения сборки.',
|
||||
cover: '/assets/illustrations/ctfe-engine.svg',
|
||||
problem: 'Функция вызывается как обычная, но разработчик не знает, когда выполняется её работа. Цена ошибки — медленная сборка, неожиданные ограничения CTFE или перенос тяжёлой операции в runtime.',
|
||||
context: 'Материал о CTFE полезен, если показывает границу: компилятор может выполнить ограниченное вычисление, когда входы известны во время сборки. Это оптимизация и способ сформировать данные, а не обещание, что любой код станет compile-time.',
|
||||
mechanism: ['CTFE выполняет функцию во время компиляции, если компилятор может интерпретировать её операции и все входы известны. Результат попадает в программу как готовое значение.', 'Ограничение проявляется на границе окружения: чтение сети, времени, произвольного файла или системного состояния нельзя считать стабильной частью константы. Если результат зависит от среды, его нужно получать явно при запуске.', 'Полезный тест смотрит на две вещи: программа собирается с заданным входом, а runtime не повторяет вычисление. Для сложной таблицы нужно сравнить выигрыш времени запуска со стоимостью компиляции и размером бинарника.'],
|
||||
code: ['enum string[] routes = buildRoutes();', '', 'string[] buildRoutes() {', ' // Все входы известны компилятору.', ' return ["/", "/catalog", "/checkout"];', '}', '', 'static assert(routes.length == 3);', '', 'void main() {', ' // В runtime используем уже готовую таблицу.', ' import std.stdio : writeln;', ' writeln(routes);', '}'],
|
||||
rows: [['Вход', 'Подходит для CTFE', 'Почему'], ['Литерал строки', 'Да', 'Результат зависит только от исходника'], ['Массив конфигурации', 'Да, если он фиксирован', 'Компилятор видит все значения'], ['Файл окружения', 'Нет как общий контракт', 'Содержимое меняется между средами'], ['Сеть и время', 'Нет', 'Результат не воспроизводим при сборке']],
|
||||
steps: ['Определить, что именно известно до запуска программы.', 'Написать маленькую чистую функцию без скрытого окружения.', 'Добавить `static assert` для главного свойства результата.', 'Сравнить время сборки и размер бинарника до и после CTFE.', 'Проверить, что runtime использует готовое значение и не повторяет вычисление.', 'Оставить runtime-путь для данных, которые зависят от среды.'],
|
||||
limits: ['Поддерживаемые операции CTFE расширяются между версиями компилятора.', 'Compile-time вычисление не делает секрет безопасным: значение может попасть в бинарник.', 'Большая таблица может ускорить запуск и одновременно замедлить сборку.'],
|
||||
sources: [{ label: 'D language: CTFE', url: 'https://dlang.org/spec/function.html#ctfe', note: 'описывает условия выполнения функции при компиляции' }, { label: 'D: compile-time programming', url: 'https://dlang.org/blog/2017/04/10/the-new-ctfe-engine/', note: 'показывает исторический пример нового CTFE engine' }, { label: 'D: static assert', url: 'https://dlang.org/spec/expression.html#staticassert', note: 'фиксирует проверку свойства на этапе компиляции' }],
|
||||
},
|
||||
'особенности-vibe-d': {
|
||||
title: 'Vibe.d: асинхронный сервер D начинается с границы владения запросом',
|
||||
excerpt: 'Как читать Vibe.d не как набор удобных API, а как контракт event loop, обработчика запроса и внешних зависимостей.',
|
||||
cover: '/assets/illustrations/vibe-api.svg',
|
||||
problem: 'Асинхронный обработчик выглядит коротко, но скрывает блокирующую работу или не закрывает ресурс. Цена ошибки — занятые worker-ы, рост очереди и таймауты, которые проявляются только под нагрузкой.',
|
||||
context: 'В исходном материале много полезных возможностей Vibe.d. Редактура сжимает перечень и оставляет то, что помогает принять решение: где живёт request context, какая операция блокирует loop и как закрыть соединение.',
|
||||
mechanism: ['Vibe.d строит сервер вокруг event loop и асинхронных операций. `yield` и `async` освобождают текущую задачу только там, где библиотека действительно умеет ждать без блокировки потока.', 'Синхронная работа с диском, CPU или библиотекой без async-адаптера остаётся синхронной. Если выполнить её внутри обработчика, таймаут клиента станет симптомом занятого worker-а, а не проблемой HTTP.', 'Запрос должен иметь явный срок жизни. Таймер, отмена и обработка исключения должны закрыть или вернуть ресурс; иначе ошибки будут накапливаться в долгом процессе.'],
|
||||
code: ['import vibe.http.server;', 'import vibe.http.router;', 'import vibe.data.json : serializeToJsonString;', '', 'auto router = new URLRouter;', 'router.get("/health", (HTTPServerRequest req,', ' HTTPServerResponse res) {', ' res.writeBody(`{"status":"ok"}`, "application/json");', '});', '', 'listenHTTP(new HTTPServerSettings, router);', 'runApplication();'],
|
||||
rows: [['Операция', 'Владелец ожидания', 'Риск'], ['HTTP-запрос', 'Router и handler', 'Не обработанное исключение'], ['База', 'Async client или worker', 'Блокировка event loop'], ['Файл', 'Отдельный worker/async API', 'Долгий диск держит поток'], ['Таймаут', 'Контракт endpoint', 'Клиент ждёт дольше, чем сервер']],
|
||||
steps: ['Назвать время жизни request и response.', 'Отделить быстрый handler от базы, файла и внешнего HTTP.', 'Для каждого ожидания определить async-операцию или worker.', 'Добавить timeout и обработчик исключения на границе запроса.', 'Проверить отмену и закрытие ресурсов при ошибке клиента.', 'Снять простой профиль очереди до вывода о производительности.'],
|
||||
limits: ['API и idioms Vibe.d менялись между версиями; сверяйте документацию и версию компилятора.', 'Пример не является готовой production-конфигурацией TLS, прокси или логирования.', 'Асинхронная запись не гарантирует меньшую задержку, если узкое место — внешний сервис.'],
|
||||
sources: [{ label: 'Vibe.d documentation', url: 'https://vibed.org/docs', note: 'описывает серверный API и асинхронную модель' }, { label: 'Vibe.d source', url: 'https://github.com/vibe-d/vibe.d', note: 'помогает сверить актуальные примеры и версии' }, { label: 'D language: fibers', url: 'https://dlang.org/library/core/thread/fiber.html', note: 'объясняет базовый механизм кооперативного переключения' }],
|
||||
},
|
||||
'вирус-самопроизвольный-запуск-брауз': {
|
||||
title: 'Windows. Браузер сам открывает сайты: безопасная диагностика без запуска подозрительного файла',
|
||||
excerpt: 'Порядок проверки автозапуска, расширений, прокси, DNS и задач Windows, когда браузер сам переходит на вредоносные страницы.',
|
||||
cover: '/assets/illustrations/cover-virus.svg',
|
||||
problem: 'Браузер сам открывает вкладки или перенаправляет поиск. Цена ошибки — потеря сессий и данных, а попытка удалить первый найденный файл может уничтожить доказательства и оставить автозапуск.',
|
||||
context: 'Старую заметку о симптоме нельзя превращать в инструкцию по работе с вредоносным кодом. Безопасная версия ограничивается наблюдением, резервной копией, проверкой настроек и официальным сканером. Подозрительные файлы не запускаем и не публикуем.',
|
||||
mechanism: ['Перенаправление может идти из расширения браузера, прокси, DNS, запланированной задачи, ярлыка или вредоносной программы. Один симптом не даёт права выбрать виновника.', 'Сначала фиксируем URL, время и активный профиль, затем отключаем сеть, если есть риск утечки. После этого проверяем расширения и настройки в чистом профиле, а системные изменения — через безопасный режим или доверенный антивирус.', 'Логи и образ диска важнее ручного удаления. Если на компьютере есть рабочие секреты, после изоляции их нужно отозвать с чистого устройства.'],
|
||||
code: ['# PowerShell: только чтение настроек и задач', 'Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"', 'Get-ItemProperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Run"', 'Get-ScheduledTask | Where-Object {$_.State -ne "Disabled"}', 'Get-NetIPConfiguration', 'netsh winhttp show proxy', '', '# После сбора данных запустить Microsoft Defender Offline,', '# а не найденный неизвестный .exe.'],
|
||||
rows: [['Слой', 'Наблюдение', 'Безопасное действие'], ['Браузер', 'Расширение или домашняя страница', 'Отключить неизвестное, проверить чистый профиль'], ['Сеть', 'Proxy/DNS отличаются от политики', 'Сверить с администратором и зафиксировать настройки'], ['Автозапуск', 'Run и Task Scheduler', 'Сохранить запись, затем удалить только подтверждённый entry'], ['Учётные данные', 'Сессии могли быть открыты', 'Отозвать токены с чистого устройства']],
|
||||
steps: ['Записать симптом и отключить сетевую связь, если есть подозрение на утечку.', 'Не запускать и не пересылать подозрительный файл; сохранить его только по процедуре организации.', 'Проверить расширения и ярлык браузера в новом профиле.', 'Собрать список автозапуска, задач, proxy и DNS только на чтение.', 'Запустить официальный offline-сканер и передать результаты специалисту.', 'После очистки сменить пароли и отозвать сессии с заведомо чистого устройства.'],
|
||||
limits: ['Команды PowerShell только читают конфигурацию; они не доказывают отсутствие вредоносного кода.', 'При корпоративном инциденте порядок организации и сохранение образа диска важнее ручной очистки.', 'Не отключайте защиту Windows и не публикуйте подозрительные образцы в статье или issue tracker.'],
|
||||
sources: [{ label: 'Microsoft Defender Offline', url: 'https://support.microsoft.com/en-us/defender/troubleshoot-problems-with-detecting-and-removing-malware', note: 'описывает проверку вне запущенной Windows' }, { label: 'Mozilla Support: unwanted redirects', url: 'https://support.mozilla.org/en-US/kb/troubleshoot-firefox-issues-using-safe-mode', note: 'помогает отделить расширение от системной причины' }, { label: 'CISA: malware mitigation', url: 'https://www.cisa.gov/sites/default/files/publications/malware-threats-mitigation.pdf', note: 'фиксирует базовые действия при подозрении на вредоносное ПО' }],
|
||||
},
|
||||
'компиляция-64-x-разрядных-программ-на-dmd-по': {
|
||||
title: 'DMD под Windows x64: проверить разрядность компилятора и бинарника',
|
||||
excerpt: 'Как собрать 64-битную программу на DMD под Windows и не перепутать архитектуру компилятора с архитектурой исходного кода.',
|
||||
cover: '/assets/illustrations/dmd-win64.svg',
|
||||
problem: 'Команда сборки проходит, но бинарник не запускается на целевой системе или получает неверную зависимость. Цена ошибки — отлаживать код, когда причина находится в разрядности toolchain и DLL.',
|
||||
context: 'Исходный рецепт `dmd -m64` остаётся отправной точкой. Для воспроизводимости нужно дополнительно зафиксировать версию DMD, linker, PATH и способ проверки PE-заголовка.',
|
||||
mechanism: ['Флаг `-m64` задаёт архитектуру результата, но итог зависит и от установленного компилятора, линкера и библиотек. Наличие DMD в PATH не доказывает, что запускается ожидаемая версия.', '64-битный бинарник может не стартовать из-за отсутствующей DLL или несовместимого runtime. Поэтому после сборки проверяем не только размер файла, но и зависимости на чистой машине.', 'Debug и release могут использовать разные настройки и пути. Их нужно собирать в одинаково описанном окружении и не подменять ручным копированием DLL.'],
|
||||
code: ['@echo off', 'where dmd', 'dmd --version', 'dub --version', '', 'dmd -m64 -of=bin\\app.exe source\\app.d', 'if errorlevel 1 exit /b 1', '', 'dumpbin /headers bin\\app.exe | findstr machine', 'dumpbin /dependents bin\\app.exe'],
|
||||
rows: [['Проверка', 'Ожидаемое значение', 'Ошибка, которую ловит'], ['Compiler', 'Версия DMD зафиксирована', 'PATH указывает на другой toolchain'], ['Target', 'x64 в PE header', 'Собран x86-бинарник'], ['Runtime', 'Зависимости найдены', 'На чистой Windows нет DLL'], ['Build mode', 'debug/release назван явно', 'Сравниваются разные конфигурации']],
|
||||
steps: ['Очистить PATH от неявных старых версий и вывести `where dmd`.', 'Зафиксировать DMD, linker и зависимости проекта.', 'Собрать маленький бинарник с `-m64` и проверить код возврата.', 'Проверить PE header и список DLL.', 'Запустить release на чистой или изолированной Windows-машине.', 'Сохранить команду сборки и версии рядом с артефактом.'],
|
||||
limits: ['Флаги и поддерживаемые linkers зависят от версии DMD и Windows SDK.', '`dumpbin` входит в Visual Studio tools; на другой машине нужен эквивалентный PE-анализатор.', 'Размер файла не подтверждает разрядность и совместимость.'],
|
||||
omitPatterns: [/^Файл разбит на разделы/i],
|
||||
sources: [{ label: 'D language: download', url: 'https://dlang.org/download.html', note: 'показывает поддерживаемые версии компиляторов' }, { label: 'DMD command line', url: 'https://dlang.org/dmd-windows.html', note: 'описывает сборку DMD под Windows' }, { label: 'D language: specification', url: 'https://dlang.org/spec/', note: 'фиксирует правила языка и target-зависимые ограничения' }],
|
||||
},
|
||||
};
|
||||
|
||||
const revisions = Object.entries(cases).map(([slug, item]) => {
|
||||
const source = archiveBySlug.get(slug);
|
||||
if (!source) throw new Error('Legacy article not found: ' + slug);
|
||||
const body = [
|
||||
p(item.problem),
|
||||
p(item.context),
|
||||
figure(item.cover, item.title + ': схема границ проверки', 'Иллюстрация показывает границу между симптомом, техническим механизмом и проверяемым действием.'),
|
||||
h2('Что сохраняем из исходной заметки'),
|
||||
legacyBody(source, 6600, item.omitPatterns),
|
||||
h2('Механизм без лишних обещаний'),
|
||||
...item.mechanism.map(p),
|
||||
h2('Минимальный воспроизводимый пример'),
|
||||
p('Ниже — маленькая проверка, которую можно запустить или адаптировать в отдельном тестовом окружении. Значения демонстрационные; проектные идентификаторы, пути и версии нужно заменить своими и сохранить рядом с результатом.'),
|
||||
codeBlock(item.code),
|
||||
h2('Матрица диагностики'),
|
||||
table(item.rows[0], item.rows.slice(1)),
|
||||
h2('Порядок действий'),
|
||||
orderedList(item.steps),
|
||||
h2('Ограничения и безопасный следующий шаг'),
|
||||
...item.limits.map(p),
|
||||
p('После проверки должен остаться конкретный артефакт: вывод команды, тест, diff конфигурации или запись результата. Если его нет, формулировку нужно вернуть к симптому и не выдавать гипотезу за исправление.'),
|
||||
h2('Что записать в ревью'),
|
||||
p('Короткая запись должна отвечать на четыре вопроса: какой вход использовали, какой результат увидели, какая граница была проверена и какое действие разрешено дальше. Такая форма полезнее длинного вывода «всё работает»: другой инженер сможет повторить проверку и понять, где заканчивается пример.'),
|
||||
p('Если результат зависит от версии Windows, PHP, Bitrix, D или браузера, версию фиксируем рядом с командой. Если проверка не охватывает сеть, production или реальные пользовательские данные, это ограничение пишем прямо. Тогда следующий шаг расширяет evidence, а не расширяет обещание.'),
|
||||
h2('Проверяемые источники'),
|
||||
sources(item.sources),
|
||||
].join('\n');
|
||||
const bodyCharacters = plainText(body.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/i, '')).length;
|
||||
if (bodyCharacters < 5000 || bodyCharacters > 15000) {
|
||||
throw new Error(slug + ': body length ' + bodyCharacters);
|
||||
}
|
||||
return {
|
||||
slug,
|
||||
title: item.title,
|
||||
excerpt: item.excerpt,
|
||||
categories: source.categories,
|
||||
cover: item.cover,
|
||||
readingMinutes: 12,
|
||||
contentHtml: body,
|
||||
proseLength: bodyCharacters,
|
||||
};
|
||||
});
|
||||
|
||||
export { revisions };
|
||||
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
} else if (process.argv[1]?.endsWith('upgrade-legacy-archive.mjs')) {
|
||||
console.log('Usage: node web/scripts/upgrade-legacy-archive.mjs --print-revisions');
|
||||
}
|
||||
Reference in New Issue
Block a user