This commit is contained in:
@@ -69,6 +69,7 @@ import { revisions as november2023Revisions } from '../scripts/upgrade-2023-11.m
|
||||
import { revisions as december2023Revisions } from '../scripts/upgrade-2023-12.mjs';
|
||||
import { revisions as january2024Revisions } from '../scripts/upgrade-2024-01.mjs';
|
||||
import { revisions as february2024Revisions } from '../scripts/upgrade-2024-02.mjs';
|
||||
import { revisions as march2024Revisions } from '../scripts/upgrade-2024-03.mjs';
|
||||
|
||||
// This layer replaces archived source entries without losing their stable slug and date.
|
||||
export const editorialRevisions = [
|
||||
@@ -143,4 +144,5 @@ export const editorialRevisions = [
|
||||
...december2023Revisions,
|
||||
...january2024Revisions,
|
||||
...february2024Revisions,
|
||||
...march2024Revisions,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 680" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Маршрут разбора запрещённого импорта</title>
|
||||
<desc id="desc">Четыре шага ведут от симптома domain type в utility к причине неописанного public API, проверке фиксированного synthetic edge и действию: вернуть смысл доменному owner-у, оставить root API и зафиксировать route. Красная стрелка показывает, что deep import в internal subpath останавливают до изменения.</desc>
|
||||
<rect width="960" height="680" fill="#F7F9FC"/>
|
||||
<rect x="44" y="36" width="872" height="72" rx="16" fill="#16325C"/>
|
||||
<text x="78" y="78" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="30" font-weight="700">Маршрут: симптом → причина → проверка → действие</text>
|
||||
<text x="78" y="98" fill="#DCE8FF" font-family="Arial, sans-serif" font-size="16">Разбор одного fixed synthetic edge до конфигурации настоящего инструмента</text>
|
||||
|
||||
<rect x="56" y="170" width="194" height="190" rx="16" fill="#FFF1F3" stroke="#C83349" stroke-width="4"/>
|
||||
<text x="78" y="216" fill="#8D1D31" font-family="Arial, sans-serif" font-size="22" font-weight="700">1. Симптом</text>
|
||||
<text x="78" y="252" fill="#A93649" font-family="Arial, sans-serif" font-size="17">utility imports</text>
|
||||
<text x="78" y="280" fill="#A93649" font-family="Arial, sans-serif" font-size="18" font-weight="700">InvoiceStatus</text>
|
||||
<text x="78" y="316" fill="#A93649" font-family="Arial, sans-serif" font-size="15">или consumer идёт</text>
|
||||
<text x="78" y="338" fill="#A93649" font-family="Arial, sans-serif" font-size="15">в internal subpath</text>
|
||||
|
||||
<rect x="282" y="170" width="194" height="190" rx="16" fill="#FFF8E5" stroke="#AA6A00" stroke-width="4"/>
|
||||
<text x="304" y="216" fill="#6A4200" font-family="Arial, sans-serif" font-size="22" font-weight="700">2. Причина</text>
|
||||
<text x="304" y="252" fill="#825607" font-family="Arial, sans-serif" font-size="17">не названы root</text>
|
||||
<text x="304" y="278" fill="#825607" font-family="Arial, sans-serif" font-size="17">API, owner и</text>
|
||||
<text x="304" y="304" fill="#825607" font-family="Arial, sans-serif" font-size="17">запрещённые</text>
|
||||
<text x="304" y="330" fill="#825607" font-family="Arial, sans-serif" font-size="17">направления</text>
|
||||
|
||||
<rect x="508" y="170" width="194" height="190" rx="16" fill="#E8F1FF" stroke="#2767BE" stroke-width="4"/>
|
||||
<text x="530" y="216" fill="#16325C" font-family="Arial, sans-serif" font-size="22" font-weight="700">3. Проверка</text>
|
||||
<text x="530" y="252" fill="#294B77" font-family="Arial, sans-serif" font-size="17">fixed synthetic</text>
|
||||
<text x="530" y="278" fill="#294B77" font-family="Arial, sans-serif" font-size="17">edge сверяется</text>
|
||||
<text x="530" y="304" fill="#294B77" font-family="Arial, sans-serif" font-size="17">с API record</text>
|
||||
<text x="530" y="330" fill="#294B77" font-family="Arial, sans-serif" font-size="15">не repository scan</text>
|
||||
|
||||
<rect x="734" y="170" width="170" height="190" rx="16" fill="#ECF9F0" stroke="#26834A" stroke-width="4"/>
|
||||
<text x="756" y="216" fill="#154B2A" font-family="Arial, sans-serif" font-size="22" font-weight="700">4. Действие</text>
|
||||
<text x="756" y="252" fill="#245B37" font-family="Arial, sans-serif" font-size="16">domain meaning</text>
|
||||
<text x="756" y="276" fill="#245B37" font-family="Arial, sans-serif" font-size="16">→ domain owner</text>
|
||||
<text x="756" y="310" fill="#245B37" font-family="Arial, sans-serif" font-size="16">consumer → root</text>
|
||||
<text x="756" y="334" fill="#245B37" font-family="Arial, sans-serif" font-size="16">API or review</text>
|
||||
|
||||
<path d="M250 265 L282 265" fill="none" stroke="#C83349" stroke-width="5" marker-end="url(#arrowRed)"/>
|
||||
<path d="M476 265 L508 265" fill="none" stroke="#AA6A00" stroke-width="5" marker-end="url(#arrowGold)"/>
|
||||
<path d="M702 265 L734 265" fill="none" stroke="#2767BE" stroke-width="5" marker-end="url(#arrowBlue)"/>
|
||||
|
||||
<path d="M154 420 C280 548 500 548 622 420" fill="none" stroke="#C83349" stroke-width="5" stroke-dasharray="12 10" marker-end="url(#arrowRed)"/>
|
||||
<rect x="268" y="510" width="430" height="68" rx="14" fill="#FFF0F2" stroke="#C83349" stroke-width="2"/>
|
||||
<text x="294" y="539" fill="#8D1D31" font-family="Arial, sans-serif" font-size="19" font-weight="700">Стоп: internal import не становится API молча</text>
|
||||
<text x="294" y="563" fill="#8D1D31" font-family="Arial, sans-serif" font-size="15">Нужен root export review или удаление зависимости.</text>
|
||||
|
||||
<rect x="44" y="624" width="872" height="28" rx="9" fill="#DCE8FF"/>
|
||||
<text x="62" y="643" fill="#274873" font-family="Arial, sans-serif" font-size="14">Результат fixture — decision draft only: real files, lint, CI, network и production не затрагиваются.</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrowRed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#C83349"/></marker>
|
||||
<marker id="arrowGold" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#AA6A00"/></marker>
|
||||
<marker id="arrowBlue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#2767BE"/></marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,50 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 680" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Граф границ пакета platform formatting</title>
|
||||
<desc id="desc">Orders feature и billing domain используют root API formatting package. Formatting package использует platform runtime. Пунктирная красная стрелка от formatting package к billing domain запрещена: utility не должна импортировать доменную модель.</desc>
|
||||
<rect width="960" height="680" fill="#F7F9FC"/>
|
||||
<rect x="44" y="36" width="872" height="72" rx="16" fill="#16325C"/>
|
||||
<text x="80" y="78" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="30" font-weight="700">Граница shared utility: направление зависимостей</text>
|
||||
<text x="80" y="98" fill="#DCE8FF" font-family="Arial, sans-serif" font-size="16">Учебная схема: public API меньше внутреннего устройства пакета</text>
|
||||
|
||||
<rect x="76" y="166" width="252" height="118" rx="14" fill="#E8F1FF" stroke="#2767BE" stroke-width="3"/>
|
||||
<text x="102" y="210" fill="#16325C" font-family="Arial, sans-serif" font-size="24" font-weight="700">orders feature</text>
|
||||
<text x="102" y="240" fill="#294B77" font-family="Arial, sans-serif" font-size="17">consumer</text>
|
||||
<text x="102" y="263" fill="#294B77" font-family="Arial, sans-serif" font-size="15">formatMoney from root API</text>
|
||||
|
||||
<rect x="76" y="410" width="252" height="118" rx="14" fill="#E8F1FF" stroke="#2767BE" stroke-width="3"/>
|
||||
<text x="102" y="454" fill="#16325C" font-family="Arial, sans-serif" font-size="24" font-weight="700">billing domain</text>
|
||||
<text x="102" y="484" fill="#294B77" font-family="Arial, sans-serif" font-size="17">owner InvoiceStatus</text>
|
||||
<text x="102" y="507" fill="#294B77" font-family="Arial, sans-serif" font-size="15">may use root API</text>
|
||||
|
||||
<rect x="386" y="276" width="286" height="150" rx="16" fill="#E8F7ED" stroke="#26834A" stroke-width="4"/>
|
||||
<text x="414" y="321" fill="#154B2A" font-family="Arial, sans-serif" font-size="23" font-weight="700">platform formatting</text>
|
||||
<text x="414" y="351" fill="#245B37" font-family="Arial, sans-serif" font-size="17">public: formatMoney</text>
|
||||
<text x="414" y="376" fill="#245B37" font-family="Arial, sans-serif" font-size="17">public: formatIsoDate</text>
|
||||
<text x="414" y="401" fill="#245B37" font-family="Arial, sans-serif" font-size="15">inputs: primitive values only</text>
|
||||
|
||||
<rect x="748" y="276" width="154" height="150" rx="14" fill="#FFF4D8" stroke="#AA6A00" stroke-width="3"/>
|
||||
<text x="772" y="328" fill="#6A4200" font-family="Arial, sans-serif" font-size="21" font-weight="700">platform</text>
|
||||
<text x="772" y="356" fill="#6A4200" font-family="Arial, sans-serif" font-size="21" font-weight="700">runtime</text>
|
||||
<text x="772" y="385" fill="#825607" font-family="Arial, sans-serif" font-size="15">Intl adapter</text>
|
||||
|
||||
<path d="M328 225 C354 225 358 292 386 316" fill="none" stroke="#2767BE" stroke-width="5" marker-end="url(#arrowBlue)"/>
|
||||
<text x="260" y="180" fill="#2767BE" font-family="Arial, sans-serif" font-size="15" font-weight="700">root import</text>
|
||||
<path d="M328 469 C358 469 358 413 386 385" fill="none" stroke="#2767BE" stroke-width="5" marker-end="url(#arrowBlue)"/>
|
||||
<text x="219" y="555" fill="#2767BE" font-family="Arial, sans-serif" font-size="15" font-weight="700">root import</text>
|
||||
<path d="M672 351 L748 351" fill="none" stroke="#AA6A00" stroke-width="5" marker-end="url(#arrowGold)"/>
|
||||
<text x="678" y="328" fill="#825607" font-family="Arial, sans-serif" font-size="14" font-weight="700">allowed runtime</text>
|
||||
|
||||
<path d="M442 426 C397 534 322 556 243 528" fill="none" stroke="#C83349" stroke-width="5" stroke-dasharray="12 10" marker-end="url(#arrowRed)"/>
|
||||
<rect x="380" y="526" width="294" height="74" rx="12" fill="#FFF0F2" stroke="#C83349" stroke-width="2"/>
|
||||
<text x="400" y="555" fill="#8D1D31" font-family="Arial, sans-serif" font-size="18" font-weight="700">запрещено: utility → domain</text>
|
||||
<text x="400" y="579" fill="#8D1D31" font-family="Arial, sans-serif" font-size="15">InvoiceStatus остаётся у billing owner</text>
|
||||
|
||||
<rect x="44" y="630" width="872" height="24" rx="8" fill="#DCE8FF"/>
|
||||
<text x="60" y="648" fill="#274873" font-family="Arial, sans-serif" font-size="14">Это fixed synthetic diagram: не import scan, не package.json и не production evidence.</text>
|
||||
|
||||
<defs>
|
||||
<marker id="arrowBlue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#2767BE"/></marker>
|
||||
<marker id="arrowGold" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#AA6A00"/></marker>
|
||||
<marker id="arrowRed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#C83349"/></marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 680" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Таблица публичного API formatting package</title>
|
||||
<desc id="desc">В зелёной колонке указаны root package specifier, formatMoney и formatIsoDate с примитивными входами. В красной колонке — запрещённые InvoiceStatus и internal subpath. Нижняя полоса поясняет, что public API является контрактом, а не каталогом файлов.</desc>
|
||||
<rect width="960" height="680" fill="#F7F9FC"/>
|
||||
<rect x="44" y="36" width="872" height="72" rx="16" fill="#16325C"/>
|
||||
<text x="78" y="78" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="30" font-weight="700">Public API: обещание, а не каталог файлов</text>
|
||||
<text x="78" y="98" fill="#DCE8FF" font-family="Arial, sans-serif" font-size="16">Одна utility знает формат, но не доменную модель счёта</text>
|
||||
|
||||
<rect x="56" y="148" width="416" height="430" rx="16" fill="#ECF9F0" stroke="#26834A" stroke-width="4"/>
|
||||
<rect x="56" y="148" width="416" height="68" rx="14" fill="#26834A"/>
|
||||
<text x="84" y="191" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="25" font-weight="700">Разрешённый public API</text>
|
||||
<text x="84" y="250" fill="#154B2A" font-family="Arial, sans-serif" font-size="18" font-weight="700">specifier</text>
|
||||
<text x="84" y="278" fill="#245B37" font-family="Arial, sans-serif" font-size="18">@synthetic/platform-formatting</text>
|
||||
<line x1="84" y1="301" x2="444" y2="301" stroke="#9CCCAE" stroke-width="2"/>
|
||||
<text x="84" y="338" fill="#154B2A" font-family="Arial, sans-serif" font-size="18" font-weight="700">names</text>
|
||||
<text x="84" y="366" fill="#245B37" font-family="Arial, sans-serif" font-size="18">formatMoney</text>
|
||||
<text x="84" y="393" fill="#245B37" font-family="Arial, sans-serif" font-size="18">formatIsoDate</text>
|
||||
<line x1="84" y1="416" x2="444" y2="416" stroke="#9CCCAE" stroke-width="2"/>
|
||||
<text x="84" y="453" fill="#154B2A" font-family="Arial, sans-serif" font-size="18" font-weight="700">formatMoney input → output</text>
|
||||
<text x="84" y="481" fill="#245B37" font-family="Arial, sans-serif" font-size="17">amountMinor, currencyCode, locale</text>
|
||||
<text x="84" y="509" fill="#245B37" font-family="Arial, sans-serif" font-size="17">→ formatted string</text>
|
||||
<text x="84" y="548" fill="#245B37" font-family="Arial, sans-serif" font-size="15">owner reviews every new root export</text>
|
||||
|
||||
<rect x="500" y="148" width="404" height="430" rx="16" fill="#FFF1F3" stroke="#C83349" stroke-width="4"/>
|
||||
<rect x="500" y="148" width="404" height="68" rx="14" fill="#C83349"/>
|
||||
<text x="528" y="191" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="25" font-weight="700">Не является public API</text>
|
||||
<text x="528" y="251" fill="#8D1D31" font-family="Arial, sans-serif" font-size="18" font-weight="700">domain model</text>
|
||||
<text x="528" y="280" fill="#A93649" font-family="Arial, sans-serif" font-size="19">InvoiceStatus</text>
|
||||
<text x="528" y="306" fill="#A93649" font-family="Arial, sans-serif" font-size="15">utility не интерпретирует статус счёта</text>
|
||||
<line x1="528" y1="329" x2="876" y2="329" stroke="#E5A7B1" stroke-width="2"/>
|
||||
<text x="528" y="366" fill="#8D1D31" font-family="Arial, sans-serif" font-size="18" font-weight="700">consumer deep import</text>
|
||||
<text x="528" y="395" fill="#A93649" font-family="Arial, sans-serif" font-size="17">platform-formatting/internal/*</text>
|
||||
<text x="528" y="421" fill="#A93649" font-family="Arial, sans-serif" font-size="15">file layout is not a stable promise</text>
|
||||
<line x1="528" y1="444" x2="876" y2="444" stroke="#E5A7B1" stroke-width="2"/>
|
||||
<text x="528" y="481" fill="#8D1D31" font-family="Arial, sans-serif" font-size="18" font-weight="700">utility outgoing route</text>
|
||||
<text x="528" y="510" fill="#A93649" font-family="Arial, sans-serif" font-size="17">→ @synthetic/billing-domain</text>
|
||||
<text x="528" y="536" fill="#A93649" font-family="Arial, sans-serif" font-size="15">requires a separate adapter or domain owner</text>
|
||||
|
||||
<rect x="44" y="616" width="872" height="34" rx="10" fill="#DCE8FF"/>
|
||||
<text x="62" y="638" fill="#274873" font-family="Arial, sans-serif" font-size="15">Public API = specifier + names + input/output + owner. Это не реальная export map и не lint report.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,599 @@
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const p = (text) => '<p>' + text + '</p>';
|
||||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bodyText(content) {
|
||||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{
|
||||
title: 'Node.js v20.11.1: Modules: Packages, февраль 2024',
|
||||
url: 'https://nodejs.org/download/release/v20.11.1/docs/api/packages.html',
|
||||
note: 'Первичная документация Node.js, доступная до марта 2024. Поле package.json "exports" задаёт доступные entry points; неэкспортируемые subpath для обычного package import недоступны. Node отдельно оговаривает, что это не сильная изоляция против прямого абсолютного пути. Документ не рисует архитектуру конкретного монорепозитория и не заменяет правило команды.',
|
||||
},
|
||||
{
|
||||
title: 'TypeScript 4.7: ECMAScript Module Support in Node.js, май 2022',
|
||||
url: 'https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7',
|
||||
note: 'Первичные release notes TypeScript. В режимах node16 и nodenext TypeScript поддерживает package.json "exports", "imports" и self-reference, а также различает ESM/CJS entry points и декларации. Это описание поведения компилятора, а не политика допустимых зависимостей между доменами.',
|
||||
},
|
||||
{
|
||||
title: 'ESLint v8.55.0 release notes, 01.12.2023',
|
||||
url: 'https://eslint.org/blog/2023/12/eslint-v8.55.0-released/',
|
||||
note: 'Первичный релиз ESLint, опубликованный до марта 2024: rule no-restricted-imports получила option importNamePattern. Она помогает фиксировать статические запреты импорта, но сама по себе не доказывает архитектуру, не строит полный dependency graph и не заменяет review динамических загрузок.',
|
||||
},
|
||||
];
|
||||
|
||||
function sourceList() {
|
||||
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function revision(meta, parts) {
|
||||
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
|
||||
const proseLength = bodyText(contentHtml).length;
|
||||
if (proseLength < 5000 || proseLength > 15000) {
|
||||
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
|
||||
}
|
||||
return Object.freeze({ ...meta, contentHtml, proseLength });
|
||||
}
|
||||
|
||||
const MODEL_LIMIT = 'fixed-in-memory-synthetic-package-boundary-model-no-repository-read-no-files-no-network-no-ci-no-import-graph-scan-no-production-change';
|
||||
const DEMO_SCOPE = 'synthetic-package-boundary-demo';
|
||||
const UTILITY_PACKAGE = '@synthetic/platform-formatting';
|
||||
const BILLING_PACKAGE = '@synthetic/billing-domain';
|
||||
const ORDERS_PACKAGE = '@synthetic/orders-feature';
|
||||
const RUNTIME_PACKAGE = '@synthetic/intl-runtime';
|
||||
|
||||
const PUBLIC_API = Object.freeze({
|
||||
packageName: UTILITY_PACKAGE,
|
||||
rootSpecifier: UTILITY_PACKAGE,
|
||||
exports: Object.freeze([
|
||||
Object.freeze({ name: 'formatMoney', input: 'amountMinor,currencyCode,locale', output: 'string' }),
|
||||
Object.freeze({ name: 'formatIsoDate', input: 'isoDate,locale', output: 'string' }),
|
||||
]),
|
||||
forbiddenConsumerSubpaths: Object.freeze([
|
||||
UTILITY_PACKAGE + '/internal/*',
|
||||
UTILITY_PACKAGE + '/src/*',
|
||||
]),
|
||||
forbiddenUtilityTargets: Object.freeze([
|
||||
BILLING_PACKAGE,
|
||||
'@synthetic/account-domain',
|
||||
]),
|
||||
});
|
||||
|
||||
const fixedCases = Object.freeze({
|
||||
'fixed-clean-public-api': Object.freeze({
|
||||
label: 'consumer uses only the public formatting API',
|
||||
edges: Object.freeze([
|
||||
Object.freeze({ from: ORDERS_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatMoney', relation: 'public-api' }),
|
||||
Object.freeze({ from: BILLING_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatIsoDate', relation: 'public-api' }),
|
||||
Object.freeze({ from: UTILITY_PACKAGE, to: RUNTIME_PACKAGE, specifier: RUNTIME_PACKAGE, importName: 'createFormatter', relation: 'allowed-platform-runtime' }),
|
||||
]),
|
||||
}),
|
||||
'fixed-utility-imports-domain': Object.freeze({
|
||||
label: 'utility imports a billing domain type',
|
||||
edges: Object.freeze([
|
||||
Object.freeze({ from: ORDERS_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatMoney', relation: 'public-api' }),
|
||||
Object.freeze({ from: UTILITY_PACKAGE, to: BILLING_PACKAGE, specifier: BILLING_PACKAGE + '/invoice-state', importName: 'InvoiceStatus', relation: 'domain-leak' }),
|
||||
Object.freeze({ from: UTILITY_PACKAGE, to: RUNTIME_PACKAGE, specifier: RUNTIME_PACKAGE, importName: 'createFormatter', relation: 'allowed-platform-runtime' }),
|
||||
]),
|
||||
}),
|
||||
'fixed-consumer-deep-import': Object.freeze({
|
||||
label: 'consumer bypasses public formatting API',
|
||||
edges: Object.freeze([
|
||||
Object.freeze({ from: ORDERS_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE + '/internal/formatter-cache', importName: 'createFormatterCache', relation: 'deep-import' }),
|
||||
Object.freeze({ from: BILLING_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatIsoDate', relation: 'public-api' }),
|
||||
Object.freeze({ from: UTILITY_PACKAGE, to: RUNTIME_PACKAGE, specifier: RUNTIME_PACKAGE, importName: 'createFormatter', relation: 'allowed-platform-runtime' }),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return (prototype === Object.prototype || prototype === null)
|
||||
&& Object.keys(value).length === keys.length
|
||||
&& keys.every((key) => Object.hasOwn(value, key));
|
||||
}
|
||||
|
||||
function hasDenseArray(value) {
|
||||
return Array.isArray(value)
|
||||
&& Object.keys(value).length === value.length
|
||||
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
|
||||
}
|
||||
|
||||
function hasSameCanonicalJson(actual, expected) {
|
||||
try {
|
||||
return JSON.stringify(actual) === JSON.stringify(expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function rejectBoundary(reason) {
|
||||
return Object.freeze({
|
||||
kind: 'synthetic-package-boundary-report-v1',
|
||||
syntheticOnly: true,
|
||||
accepted: false,
|
||||
reason,
|
||||
modelLimit: MODEL_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
export function createFixedSyntheticBoundaryInput(caseId) {
|
||||
if (!Object.hasOwn(fixedCases, caseId)) {
|
||||
return Object.freeze({ synthetic: false, kind: 'unknown-synthetic-package-boundary-input', caseId });
|
||||
}
|
||||
return Object.freeze({
|
||||
synthetic: true,
|
||||
kind: 'synthetic-package-boundary-input-v1',
|
||||
scope: DEMO_SCOPE,
|
||||
mode: 'fixed-memory-only',
|
||||
caseId,
|
||||
});
|
||||
}
|
||||
|
||||
function violationFor(edge) {
|
||||
if (edge.from === UTILITY_PACKAGE && PUBLIC_API.forbiddenUtilityTargets.some((target) => edge.to === target)) {
|
||||
return Object.freeze({
|
||||
code: 'utility-imports-domain',
|
||||
edge,
|
||||
rule: UTILITY_PACKAGE + ' may use only primitive formatting inputs and approved platform runtime; it may not import domain packages.',
|
||||
draftAction: 'move InvoiceStatus interpretation back to ' + BILLING_PACKAGE + ' and pass amountMinor,currencyCode,locale to formatMoney',
|
||||
});
|
||||
}
|
||||
if (edge.to === UTILITY_PACKAGE && edge.specifier !== PUBLIC_API.rootSpecifier) {
|
||||
return Object.freeze({
|
||||
code: 'consumer-bypasses-public-api',
|
||||
edge,
|
||||
rule: 'consumers import only ' + PUBLIC_API.rootSpecifier + '; internal and src subpaths are not public API.',
|
||||
draftAction: 'replace the deep import with a documented root export or add a deliberately reviewed public export',
|
||||
});
|
||||
}
|
||||
if (edge.to === UTILITY_PACKAGE && !PUBLIC_API.exports.some((entry) => entry.name === edge.importName)) {
|
||||
return Object.freeze({
|
||||
code: 'consumer-uses-unknown-public-symbol',
|
||||
edge,
|
||||
rule: 'the root entry point exposes only the reviewed names in the synthetic public API.',
|
||||
draftAction: 'choose formatMoney or formatIsoDate, or open a separate API review',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects one of three fixed synthetic import records embedded above. It does
|
||||
* not read a repository, source file, configuration, environment variable,
|
||||
* clock, package manager, CI output, network, HTTP endpoint or real import
|
||||
* graph. "compliant" and "violated" are facts only about this tiny example.
|
||||
*/
|
||||
export function inspectSyntheticPackageBoundary(input) {
|
||||
if (!input || input.synthetic !== true || input.kind !== 'synthetic-package-boundary-input-v1') {
|
||||
return rejectBoundary('synthetic-input-required');
|
||||
}
|
||||
const allowed = ['synthetic', 'kind', 'scope', 'mode', 'caseId'];
|
||||
if (!hasExactKeys(input, allowed)) return rejectBoundary('unexpected-input-field');
|
||||
if (input.scope !== DEMO_SCOPE) return rejectBoundary('unexpected-synthetic-scope');
|
||||
if (input.mode !== 'fixed-memory-only') return rejectBoundary('synthetic-mode-required');
|
||||
if (!Object.hasOwn(fixedCases, input.caseId)) return rejectBoundary('unknown-fixed-synthetic-case');
|
||||
|
||||
const fixed = fixedCases[input.caseId];
|
||||
const violations = fixed.edges.map(violationFor).filter(Boolean);
|
||||
const status = violations.length === 0 ? 'compliant' : 'violated';
|
||||
const publicApi = Object.freeze({
|
||||
packageName: PUBLIC_API.packageName,
|
||||
rootSpecifier: PUBLIC_API.rootSpecifier,
|
||||
exports: PUBLIC_API.exports,
|
||||
forbiddenConsumerSubpaths: PUBLIC_API.forbiddenConsumerSubpaths,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
kind: 'synthetic-package-boundary-report-v1',
|
||||
syntheticOnly: true,
|
||||
accepted: true,
|
||||
reason: 'fixed-synthetic-case-inspected',
|
||||
modelLimit: MODEL_LIMIT,
|
||||
scope: DEMO_SCOPE,
|
||||
caseId: input.caseId,
|
||||
caseLabel: fixed.label,
|
||||
status,
|
||||
publicApi,
|
||||
inspectedEdges: fixed.edges,
|
||||
violations: Object.freeze(violations),
|
||||
evidence: Object.freeze({ source: 'embedded-fixed-records-only', realRepository: 'not-read', realImportGraph: 'not-scanned' }),
|
||||
nextReview: status === 'compliant'
|
||||
? 'record-public-api-draft-only'
|
||||
: 'review-the-listed-synthetic-edge-before-any-real-change',
|
||||
productionEffect: 'not-attempted',
|
||||
});
|
||||
}
|
||||
|
||||
function isCanonicalSyntheticBoundaryReport(report) {
|
||||
const reportKeys = ['kind', 'syntheticOnly', 'accepted', 'reason', 'modelLimit', 'scope', 'caseId', 'caseLabel', 'status', 'publicApi', 'inspectedEdges', 'violations', 'evidence', 'nextReview', 'productionEffect'];
|
||||
if (!hasExactKeys(report, reportKeys)
|
||||
|| report.kind !== 'synthetic-package-boundary-report-v1'
|
||||
|| report.syntheticOnly !== true
|
||||
|| report.accepted !== true
|
||||
|| report.reason !== 'fixed-synthetic-case-inspected'
|
||||
|| report.modelLimit !== MODEL_LIMIT
|
||||
|| report.scope !== DEMO_SCOPE
|
||||
|| !Object.hasOwn(fixedCases, report.caseId)
|
||||
|| report.caseLabel !== fixedCases[report.caseId].label
|
||||
|| !['compliant', 'violated'].includes(report.status)
|
||||
|| report.productionEffect !== 'not-attempted') return false;
|
||||
|
||||
return hasExactKeys(report.publicApi, ['packageName', 'rootSpecifier', 'exports', 'forbiddenConsumerSubpaths'])
|
||||
&& hasDenseArray(report.publicApi.exports)
|
||||
&& hasDenseArray(report.publicApi.forbiddenConsumerSubpaths)
|
||||
&& hasDenseArray(report.inspectedEdges)
|
||||
&& hasDenseArray(report.violations)
|
||||
&& hasExactKeys(report.evidence, ['source', 'realRepository', 'realImportGraph'])
|
||||
&& report.evidence.source === 'embedded-fixed-records-only'
|
||||
&& report.evidence.realRepository === 'not-read'
|
||||
&& report.evidence.realImportGraph === 'not-scanned';
|
||||
}
|
||||
|
||||
function makeSyntheticBoundaryDecisionDraft(fresh) {
|
||||
const actions = fresh.status === 'compliant'
|
||||
? Object.freeze(['record-root-public-api', 'retain-forbidden-route-list', 'schedule-human-review'])
|
||||
: Object.freeze(fresh.violations.map((violation) => violation.draftAction));
|
||||
return Object.freeze({
|
||||
accepted: true,
|
||||
syntheticOnly: true,
|
||||
reason: 'synthetic-boundary-decision-draft',
|
||||
scope: DEMO_SCOPE,
|
||||
caseId: fresh.caseId,
|
||||
status: fresh.status,
|
||||
actions,
|
||||
lintIdea: 'draft-only: no-restricted-imports may encode named static routes after a team chooses its actual paths',
|
||||
packageExportIdea: 'draft-only: package.json exports can declare a root entry point where the runtime and compatibility policy permit it',
|
||||
realConfiguration: 'not-written',
|
||||
realLint: 'not-run',
|
||||
realCi: 'not-run',
|
||||
productionEffect: 'not-attempted',
|
||||
modelLimit: MODEL_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
function isCanonicalSyntheticBoundaryPlan(plan) {
|
||||
const planKeys = ['accepted', 'syntheticOnly', 'reason', 'scope', 'caseId', 'status', 'actions', 'lintIdea', 'packageExportIdea', 'realConfiguration', 'realLint', 'realCi', 'productionEffect', 'modelLimit'];
|
||||
if (!hasExactKeys(plan, planKeys)
|
||||
|| plan.accepted !== true
|
||||
|| plan.syntheticOnly !== true
|
||||
|| plan.reason !== 'synthetic-boundary-decision-draft'
|
||||
|| plan.scope !== DEMO_SCOPE
|
||||
|| !Object.hasOwn(fixedCases, plan.caseId)
|
||||
|| !hasDenseArray(plan.actions)
|
||||
|| plan.realConfiguration !== 'not-written'
|
||||
|| plan.realLint !== 'not-run'
|
||||
|| plan.realCi !== 'not-run'
|
||||
|| plan.productionEffect !== 'not-attempted'
|
||||
|| plan.modelLimit !== MODEL_LIMIT) return false;
|
||||
|
||||
const fresh = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput(plan.caseId));
|
||||
return fresh.accepted === true && hasSameCanonicalJson(plan, makeSyntheticBoundaryDecisionDraft(fresh));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a decision draft only for a report produced by this fixture. It does
|
||||
* not create package.json, lint config, source code, a pull request or a CI
|
||||
* check. The plan is deliberately data, not an operational command.
|
||||
*/
|
||||
export function planSyntheticBoundaryRemediation(report) {
|
||||
if (!report || report.kind !== 'synthetic-package-boundary-report-v1' || report.syntheticOnly !== true || report.accepted !== true) {
|
||||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelLimit: MODEL_LIMIT });
|
||||
}
|
||||
if (!isCanonicalSyntheticBoundaryReport(report)) {
|
||||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'untrusted-synthetic-report-shape', modelLimit: MODEL_LIMIT });
|
||||
}
|
||||
const fresh = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput(report.caseId));
|
||||
if (!hasSameCanonicalJson(report, fresh)) {
|
||||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-fixed-record', modelLimit: MODEL_LIMIT });
|
||||
}
|
||||
return makeSyntheticBoundaryDecisionDraft(fresh);
|
||||
}
|
||||
|
||||
export function rollbackSyntheticBoundaryDraft(plan) {
|
||||
if (!isCanonicalSyntheticBoundaryPlan(plan)) {
|
||||
return Object.freeze({ restored: false, syntheticOnly: true, reason: 'no-accepted-synthetic-plan' });
|
||||
}
|
||||
return Object.freeze({
|
||||
restored: true,
|
||||
syntheticOnly: true,
|
||||
reason: 'synthetic-decision-draft-discarded',
|
||||
repository: 'not-read-or-changed',
|
||||
files: 'not-read-or-written',
|
||||
network: 'not-used',
|
||||
ci: 'not-run',
|
||||
productionEffect: 'not-attempted',
|
||||
});
|
||||
}
|
||||
|
||||
export function runPackageBoundaryFixture() {
|
||||
const clean = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput('fixed-clean-public-api'));
|
||||
const domainLeak = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput('fixed-utility-imports-domain'));
|
||||
const deepImport = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput('fixed-consumer-deep-import'));
|
||||
const nonSynthetic = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), synthetic: false });
|
||||
const unexpectedField = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), repositoryPath: '/not/read' });
|
||||
const wrongScope = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), scope: 'other-scope' });
|
||||
const wrongMode = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), mode: 'scan-project' });
|
||||
const unknownCase = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), caseId: 'invented-case' });
|
||||
const cleanPlan = planSyntheticBoundaryRemediation(clean);
|
||||
const domainPlan = planSyntheticBoundaryRemediation(domainLeak);
|
||||
const forgedPlan = planSyntheticBoundaryRemediation({ ...clean, status: 'violated' });
|
||||
const unexpectedReportShape = planSyntheticBoundaryRemediation({ ...clean, repositoryPath: '/not/read' });
|
||||
const forgedViolationPlan = planSyntheticBoundaryRemediation({
|
||||
...domainLeak,
|
||||
violations: [{ ...domainLeak.violations[0], draftAction: 'write-a-real-config' }],
|
||||
});
|
||||
const restored = rollbackSyntheticBoundaryDraft(domainPlan);
|
||||
const rejectedRestore = rollbackSyntheticBoundaryDraft(domainLeak);
|
||||
const forgedRollbackPlan = rollbackSyntheticBoundaryDraft({ ...domainPlan, actions: ['write-a-real-config'] });
|
||||
const sparseActions = new Array(1);
|
||||
const sparseRollbackPlan = rollbackSyntheticBoundaryDraft({ ...domainPlan, actions: sparseActions });
|
||||
|
||||
return Object.freeze({
|
||||
assertions: Object.freeze({
|
||||
acceptsTheFixedCleanCase: clean.accepted === true && clean.status === 'compliant',
|
||||
keepsRootOnlyPublicApi: clean.publicApi.rootSpecifier === UTILITY_PACKAGE && clean.publicApi.exports.length === 2 && clean.publicApi.exports[0].name === 'formatMoney',
|
||||
keepsForbiddenConsumerSubpathsExplicit: clean.publicApi.forbiddenConsumerSubpaths.includes(UTILITY_PACKAGE + '/internal/*') && clean.publicApi.forbiddenConsumerSubpaths.includes(UTILITY_PACKAGE + '/src/*'),
|
||||
recordsOnlyFixedSyntheticEdges: clean.inspectedEdges.length === 3 && clean.evidence.source === 'embedded-fixed-records-only',
|
||||
doesNotClaimARealGraph: clean.evidence.realRepository === 'not-read' && clean.evidence.realImportGraph === 'not-scanned' && clean.productionEffect === 'not-attempted',
|
||||
findsUtilityDomainLeak: domainLeak.accepted === true && domainLeak.status === 'violated' && domainLeak.violations.length === 1 && domainLeak.violations[0].code === 'utility-imports-domain',
|
||||
namesTheDomainLeakRepair: domainLeak.violations[0].draftAction.includes('amountMinor,currencyCode,locale'),
|
||||
findsConsumerDeepImport: deepImport.accepted === true && deepImport.status === 'violated' && deepImport.violations[0].code === 'consumer-bypasses-public-api',
|
||||
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-input-required',
|
||||
rejectsUnexpectedProjectLikeField: unexpectedField.accepted === false && unexpectedField.reason === 'unexpected-input-field',
|
||||
rejectsDifferentScope: wrongScope.accepted === false && wrongScope.reason === 'unexpected-synthetic-scope',
|
||||
rejectsScanMode: wrongMode.accepted === false && wrongMode.reason === 'synthetic-mode-required',
|
||||
rejectsUnknownFixedCase: unknownCase.accepted === false && unknownCase.reason === 'unknown-fixed-synthetic-case',
|
||||
createsOnlyDecisionDraftForCleanCase: cleanPlan.accepted === true && cleanPlan.status === 'compliant' && cleanPlan.actions.includes('record-root-public-api'),
|
||||
createsOnlyDecisionDraftForViolation: domainPlan.accepted === true && domainPlan.status === 'violated' && domainPlan.actions.length === 1,
|
||||
doesNotWriteOrRunOperationalSystems: domainPlan.realConfiguration === 'not-written' && domainPlan.realLint === 'not-run' && domainPlan.realCi === 'not-run' && domainPlan.productionEffect === 'not-attempted',
|
||||
rejectsForgedReport: forgedPlan.accepted === false && forgedPlan.reason === 'report-does-not-match-fixed-record',
|
||||
rejectsUnexpectedReportShape: unexpectedReportShape.accepted === false && unexpectedReportShape.reason === 'untrusted-synthetic-report-shape',
|
||||
rejectsForgedViolationAction: forgedViolationPlan.accepted === false && forgedViolationPlan.reason === 'report-does-not-match-fixed-record',
|
||||
rollbackDiscardsOnlySyntheticDraft: restored.restored === true && restored.repository === 'not-read-or-changed' && restored.files === 'not-read-or-written',
|
||||
rollbackDoesNotTouchNetworkOrCi: restored.network === 'not-used' && restored.ci === 'not-run' && restored.productionEffect === 'not-attempted',
|
||||
rejectedReportCannotRollback: rejectedRestore.restored === false && rejectedRestore.reason === 'no-accepted-synthetic-plan',
|
||||
rejectsForgedRollbackPlan: forgedRollbackPlan.restored === false && forgedRollbackPlan.reason === 'no-accepted-synthetic-plan',
|
||||
rejectsSparseRollbackActions: sparseRollbackPlan.restored === false && sparseRollbackPlan.reason === 'no-accepted-synthetic-plan',
|
||||
}),
|
||||
samples: Object.freeze({ clean, domainLeak, deepImport, nonSynthetic, unexpectedField, wrongScope, wrongMode, unknownCase, cleanPlan, domainPlan, forgedPlan, unexpectedReportShape, forgedViolationPlan, restored, rejectedRestore, forgedRollbackPlan, sparseRollbackPlan }),
|
||||
});
|
||||
}
|
||||
|
||||
const syntheticExample = `import {
|
||||
createFixedSyntheticBoundaryInput,
|
||||
inspectSyntheticPackageBoundary,
|
||||
planSyntheticBoundaryRemediation,
|
||||
runPackageBoundaryFixture,
|
||||
} from './upgrade-2024-03.mjs';
|
||||
|
||||
const report = inspectSyntheticPackageBoundary(
|
||||
createFixedSyntheticBoundaryInput('fixed-utility-imports-domain'),
|
||||
);
|
||||
const draft = planSyntheticBoundaryRemediation(report);
|
||||
|
||||
if (!Object.values(runPackageBoundaryFixture().assertions).every(Boolean)) {
|
||||
throw new Error('synthetic fixture failed');
|
||||
}
|
||||
|
||||
console.log(report.status); // violated
|
||||
console.log(draft.realCi); // not-run
|
||||
|
||||
// Здесь нет чтения репозитория, файлов, сети, CI или реального import graph.`;
|
||||
|
||||
const fixtureCommand = syntheticExample + '\n\nnode web/scripts/upgrade-2024-03.mjs --verify-fixture\n\n# PASS подтверждает только согласованность fixed synthetic records и отрицательных веток.';
|
||||
|
||||
const practice = revision({
|
||||
slug: 'editorial-2024-03-practice-package-boundaries',
|
||||
title: 'Границы пакетов: как остановить общую утилиту до того, как она станет платформой',
|
||||
categories: ['JavaScript', 'Архитектура'],
|
||||
cover: '/assets/editorial/2024/package-boundaries-2024-package-graph.svg',
|
||||
excerpt: 'Практический маршрут для случая, когда shared-утилита начинает импортировать доменную модель: короткий public API, запрещённые направления и проверка без легенды о реальном графе зависимостей.',
|
||||
readingMinutes: 12,
|
||||
}, [
|
||||
p('Симптом обычно появляется в маленьком pull request. В общей утилите форматирования просят учесть `InvoiceStatus`, потому что так удобнее вывести подпись рядом с суммой. Через неделю второй потребитель берёт внутренний cache этой утилиты, а третий ждёт от неё ещё один доменный флаг. Цена не в самом импорте. Пакет, который считали нейтральной функцией, начинает владеть чужим смыслом. Изменение статуса счёта теперь способно затронуть экран заказа, а ремонт formatter-а требует знать правила billing. Стоимость растёт в review, обновлениях и откатах: у команды больше нет малого места, которое можно менять изолированно.'),
|
||||
p('Не надо отвечать на это большим переносом директорий. Сначала нужно назвать границу. Общая утилита полезна, пока принимает данные, не интерпретируя доменную историю: minor units, currency code, locale, ISO date. `InvoiceStatus`, лимиты возврата и правило «показывать счёт как просроченный» принадлежат billing-домену. Если utility импортирует тип или enum, чтобы решить, что показать, она становится транзитной точкой доменной политики. Тогда любой новый consumer получает не только функцию, но и скрытое право зависеть от чужого языка.'),
|
||||
h2('Симптом → причина → проверка → действие'),
|
||||
ol([
|
||||
'<strong>Симптом.</strong> В описании задачи звучит «добавим одно условие в shared helper», а имя импортируемого объекта относится к заказу, счету, клиенту или другому домену.',
|
||||
'<strong>Причина.</strong> В utility нет записанного public API. Потребитель видит файлы и считает любой внутренний symbol доступным; домен видит свободную функцию и переносит в неё собственное решение.',
|
||||
'<strong>Проверка.</strong> Для одного пакета перечислите root specifier, экспортируемые имена, входы, выход и два запрещённых направления: consumer не ходит в `/internal`, utility не импортирует domain package.',
|
||||
'<strong>Действие.</strong> Оставьте в utility преобразование примитивных входов, верните доменную интерпретацию в owner-package и запишите запрет рядом с public API. Автоматическую проверку добавляют только после того, как команда согласовала эти слова.',
|
||||
]),
|
||||
h2('Минимальный public API, а не каталог файлов'),
|
||||
p('Public API — это не всё, что случайно экспортируется из исходной папки. Это короткий договор: какой module specifier разрешён, какие имена можно импортировать, что получает функция и что она возвращает. У форматтера API может быть меньше пяти строк. Важна не красота декларации, а отсутствие доменной дырки. `formatMoney({ amountMinor, currencyCode, locale })` получает числа и строковые коды и возвращает строку. Он не принимает `Invoice`, не знает `InvoiceStatus` и не решает, разрешён ли возврат.'),
|
||||
p('Такой API заставляет consumer сделать полезную работу у себя. Billing выбирает состояние счёта и вызывает formatter только для денег. Orders-feature тоже может вызвать formatter, но не вынужден таскать billing-модель. Это не запрет на переиспользование. Это разделение ответственности: общая функция владеет представлением примитивов, домен — значением собственных объектов. Если два домена действительно договорились об одном бизнес-правиле, оно должно жить в явно названном общедоменно́м модуле с owner и версией, а не прятаться внутри «utils».') ,
|
||||
table('Контракт маленького formatting-пакета в учебном примере', ['Элемент', 'Разрешено', 'Запрещено', 'Почему'], [
|
||||
['Specifier потребителя', '`@synthetic/platform-formatting`', '`@synthetic/platform-formatting/internal/*`', 'root entry point остаётся единственной точкой обещания'],
|
||||
['Публичные имена', '`formatMoney`, `formatIsoDate`', 'случайные cache и private helper', 'внутренность можно менять без миграции всех consumers'],
|
||||
['Вход `formatMoney`', '`amountMinor`, `currencyCode`, `locale`', '`Invoice`, `InvoiceStatus`, правило скидки', 'утилита не получает доменную модель'],
|
||||
['Исходящие зависимости utility', 'согласованный runtime formatting', 'billing и account domains', 'домен не протекает в общую платформенную точку'],
|
||||
['Решение при нарушении', 'короткий review и новый контракт', 'тихий deep import или перенос enum', 'изменение становится видимым до распространения'],
|
||||
]),
|
||||
figure('/assets/editorial/2024/package-boundaries-2024-package-graph.svg', 'Схема учебного графа: orders feature и billing domain используют root API platform formatting; utility использует только platform runtime. Красная пунктирная стрелка от utility к billing domain помечена как запрещённая граница.', 'Граф показывает правило направления, а не результат сканирования проекта. Все названия с префиксом synthetic существуют только в памяти учебной модели.'),
|
||||
h2('Где поставить реальную границу'),
|
||||
p('Начните не с названия папки, а с вопроса: «какой факт должен остаться верным, если billing меняет свою модель?» Для formatter-а ответ прост: он всё ещё умеет превратить amount и currency в отображаемую строку. Если невозможно сформулировать вход без объекта другого домена, значит функция не общая. Её место либо в billing, либо в отдельном пакете, который явно владеет общим словарём и имеет собственный контракт.'),
|
||||
p('Полезно записать и запрещённый маршрут, а не только allowed API. Два правила из примера достаточно жёсткие, но понятные: consumer не импортирует `internal` и `src`; platform formatting не импортирует `@synthetic/billing-domain` и `@synthetic/account-domain`. Запрет не утверждает, что любые пакеты обязаны быть изолированы. Он охраняет конкретную роль пакета. Отдельному adapter-у, который намеренно соединяет billing с UI, нужны другое имя, другой owner и собственные допустимые зависимости.'),
|
||||
h2('Что можно сделать механизмами Node.js, TypeScript и ESLint'),
|
||||
p('У этих инструментов разные полномочия. Node.js `package.json` `exports` умеет объявить доступные entry points package import-а. В документации Node v20.11.1 сказано, что неэкспортируемые subpath становятся недоступны обычному import, но это не сильная изоляция против прямого абсолютного пути. Значит `exports` полезен как runtime/package contract, но не заменяет архитектурный review и не защищает любой способ доступа к файлу.'),
|
||||
p('TypeScript 4.7 добавил режимы `node16` и `nodenext`, которые понимают `exports`, `imports` и self-reference. Это важно для совпадения type-checking с форматом package entry point, особенно когда ESM и CJS имеют разные точки входа. Но compiler не знает бизнес-смысл `InvoiceStatus`. Он может подтвердить, что specifier разрешается, но не решает, должен ли formatter зависеть от billing. Такое решение остаётся в контракте пакета.'),
|
||||
p('ESLint `no-restricted-imports` подходит для точного статического запрета после согласования пути. К марту 2024 правило уже умело ограничивать import paths, а релиз ESLint 8.55.0 добавил `importNamePattern`. Однако это слой проверки статического синтаксиса, не средство построить достоверный граф всего проекта. Dynamic import, generated code, aliases и runtime resolution требуют отдельно проверять область применимости. Не записывайте в policy обещание, которое выбранный linter не умеет выполнять.'),
|
||||
h2('Исполнимый fixed synthetic пример'),
|
||||
p('Ниже fixture не открывает package.json и не ходит по каталогам. Внутри модуля уже лежат три фиксированные записи: чистый root import, импорт доменного `InvoiceStatus` самой utility и deep import consumer-а. Функция принимает только case id с явным `synthetic` marker и проверяет правила на этих объектах. Это удобная проверка формы контракта: она показывает, что «domain leak» и «public API bypass» не смешаны в одном общем сообщении.'),
|
||||
code(fixtureCommand),
|
||||
p('PASS fixture означает только согласованность заранее записанных synthetic records и их отрицательных веток. Он не читает рабочее дерево, не строит import graph, не знает фактического `package.json`, не запускает lint или CI и не меняет production. Это намеренное ограничение. Тест, который называет себя boundary check, но тихо опирается на состояние неизвестного репозитория, плохо объясняет, какие именно правила он подтвердил.'),
|
||||
h2('Переход без большой миграции'),
|
||||
p('Сначала остановите расширение поверхности. Опубликуйте короткий root API и для новой задачи требуйте один из двух исходов: использовать существующее имя или открыть отдельное API review. Затем выберите один доменный импорт из utility, перенесите интерпретацию обратно в owner-package и добавьте consumer-side adapter, если ему нужны данные в другом виде. После этого объявите `internal` приватным в документации и настройте выбранный механизм контроля только для уже согласованных путей.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Эта схема не измеряет размер bundle, не оценивает циклы, не проверяет семантическую совместимость API и не устанавливает универсальную слоистость. Node `exports` работает в своих runtime и compatibility условиях; TypeScript modes требуют соответствующей конфигурации; ESLint rule охватывает статические import statements в выбранной настройке. Для legacy-кода может понадобиться временный adapter и срок удаления. Для runtime plugin system статический запрет вообще не описывает все связи.'),
|
||||
p('Следующий шаг — выбрать один настоящий shared package и оформить one-page boundary record: owner, root specifier, public names, input/output, allowed incoming consumers, forbidden outgoing domains, способ проверки и дата следующего review. Пока такой record не существует, не называйте функцию платформой. Когда он появится, каждый новый импорт станет коротким проверяемым вопросом, а не очередным исключением в общей утилите.'),
|
||||
h2('Историческая граница марта 2024'),
|
||||
p('К марту 2024 уже были доступны Node `exports` (в Node с 12.7.0; здесь взята официальная документация v20.11.1), TypeScript 4.7 с поддержкой Node-oriented package resolution и ESLint 8.55.0. Материал использует их как строительные блоки, но не приписывает им более поздние возможности. Голос M7 здесь практичный: сначала цена зависимости, затем контракт, ограничение инструмента и воспроизводимая проверка без выдуманного production-опыта.'),
|
||||
]);
|
||||
|
||||
const mechanism = revision({
|
||||
slug: 'editorial-2024-03-mechanism-package-boundaries',
|
||||
title: 'Границы пакетов: public API, запрещённые импорты и три уровня защиты',
|
||||
categories: ['JavaScript', 'Архитектура'],
|
||||
cover: '/assets/editorial/2024/package-boundaries-2024-public-api-table.svg',
|
||||
excerpt: 'Механика границы пакета: чем отличаются public API, runtime exports и статический запрет импорта; как не принять типовую совместимость за право протащить доменную модель в общую утилиту.',
|
||||
readingMinutes: 12,
|
||||
}, [
|
||||
p('Сбой границы редко выглядит как архитектурный спор. Сначала TypeScript без возражений принимает `import { InvoiceStatus } from "@domain/billing"` в shared formatter. Затем другой пакет использует private helper, потому что autocomplete его нашёл. Оба импорта работают сегодня. Цена появляется позже: новый billing enum вынуждает выпускать utility, а рефакторинг cache требует искать consumers, которых никто не считал частью API. Команда получает связность без владельца и пытается лечить её новым alias или исключением в linter.'),
|
||||
p('Причина в том, что слово «граница» смешивает три разные вещи. Public API отвечает, что package обещает consumers. Runtime/package metadata отвечает, какие package entry points разрешает resolver. Static policy отвечает, какие import routes команда считает недопустимыми в данном слое. Если назвать их одним механизмом, появится ложная уверенность: `exports` объявляют архитектуру, TypeScript якобы запрещает домены, а lint якобы знает полный граф. Ни одно из этих утверждений не верно без явного контракта.'),
|
||||
h2('Модель: пакет обещает меньше, чем содержит'),
|
||||
p('Пакет всегда содержит больше, чем должен обещать. Внутри formatter-а могут быть cache key, locale fallback и вспомогательный adapter. Consumer не должен строить на них зависимость, иначе любая перестройка внутреннего кода становится breaking change. Public API сужает поверхность до root specifier и нескольких имён. Это не попытка спрятать знания от коллег; это способ зафиксировать, какие изменения требуют миграции и какой owner принимает решение о расширении интерфейса.'),
|
||||
p('Доменные типы требуют отдельного внимания. TypeScript type-only import может исчезнуть из emitted JavaScript, но архитектурная зависимость остаётся в исходном коде: formatter начинает понимать чужой словарь, а его declarations начинают отражать этот словарь. Поэтому проверка «в bundle нет billing» недостаточна. Вопрос другой: может ли команда изменить billing-модель, не открывая контракт shared package? Если ответ нет, зависимость уже существует, даже если она была type-only.'),
|
||||
table('Три слоя одной границы', ['Слой', 'Что он проверяет', 'Чего он не доказывает', 'Практический вывод'], [
|
||||
['Public API record', 'допустимые specifier, names, входы, выходы, owner', 'runtime resolution и все реальные imports', 'сначала договоритесь о смысле'],
|
||||
['Node package.json `exports`', 'доступные entry points при package import', 'сильную изоляцию от прямого абсолютного пути и доменную политику', 'используйте для package surface при подходящем runtime'],
|
||||
['TypeScript node16/nodenext', 'согласованное разрешение `exports`/`imports` и module format', 'право одного домена знать модель другого', 'держите compiler и runtime в одной модели'],
|
||||
['ESLint `no-restricted-imports`', 'названные статические import routes и, в нужной версии, pattern rules', 'полный граф, dynamic imports и смысл модели', 'закодируйте уже принятый локальный запрет'],
|
||||
['Human review', 'стоит ли новый факт включать в обещание package', 'машинную полноту без наблюдаемого evidence', 'принимает исключение или создаёт отдельный adapter'],
|
||||
]),
|
||||
figure('/assets/editorial/2024/package-boundaries-2024-public-api-table.svg', 'Таблица public API учебного platform formatting package: root specifier предоставляет formatMoney и formatIsoDate с примитивными входами; в красной зоне находятся InvoiceStatus и internal subpath.', 'Схема отделяет контракт функции от файлового устройства. Она не показывает настоящий package.json и не утверждает, что эти exports опубликованы в каком-либо registry.'),
|
||||
h2('Уровень 1: записать контракт до конфигурации'),
|
||||
p('Контракт должен быть настолько мал, чтобы reviewer смог прочитать его без поиска по всему репозиторию. Для `@synthetic/platform-formatting` достаточно зафиксировать root specifier, `formatMoney`, `formatIsoDate`, входы и выходы. За пределами остаются `internal` и `src`, доменные types и business decisions. Появление нового export-а — не строка в barrel file, а изменение public surface: нужен owner, потребитель, причина, migration story и версия, если пакет имеет внешних клиентов.'),
|
||||
p('Запрет тоже должен быть написан в терминах направлений. «Не импортировать домены» слишком широко: adapter, который по задаче соединяет UI и billing, станет ложным нарушением. Точнее так: package с ролью `platform-formatting` не импортирует `billing-domain` и `account-domain`; потребители этого package не ходят в `platform-formatting/internal/*`; package с ролью billing может импортировать root API formatter-а. У правила появляются адресат, исключение и проверяемый route.'),
|
||||
h2('Уровень 2: package metadata не равна архитектуре'),
|
||||
p('В Node.js поле `exports` разрешает явно описать main entry point и subpath exports. Официальная документация Node v20.11.1 объясняет, что при наличии `exports` неописанные subpath не доступны обычному `import "package/subpath"`; это делает surface package надёжнее для tools и semver-изменений. Но там же есть важная граница: абсолютный путь к файлу может обойти такую инкапсуляцию. Поэтому `exports` нельзя продавать команде как security boundary или доказательство отсутствия плохих imports.'),
|
||||
h2('Уровень 3: статический запрет должен быть узким'),
|
||||
p('После контракта можно поставить статический guard. Например, policy для consumer-слоя запрещает `@synthetic/platform-formatting/internal/*` и предлагает root API. Policy для utility запрещает `@synthetic/billing-domain/*` и предлагает вернуть интерпретацию status в billing. ESLint `no-restricted-imports` создан именно для ограничения конкретных static imports; опубликованный 1 декабря 2023 ESLint 8.55.0 добавил `importNamePattern`. Для простого маршрута этого достаточно: error показывает edge и альтернативу, а не абстрактное «нарушение архитектуры».'),
|
||||
h2('Пример: от domain type к primitive contract'),
|
||||
p('Плохой вариант не обязательно выглядит огромным. Formatter получает `InvoiceStatus`, выбирает текст «Просрочен» и добавляет вид currency. В нём уже два разных вопроса: как интерпретировать состояние счёта и как отобразить деньги. Разделение выглядит скромно: billing переводит status в свою label или display model, а formatter получает amount, currency и locale. Это не делает код безошибочным, но возвращает изменение status в domain package и оставляет utility независимой от его enum.'),
|
||||
code(`// Синтетический контракт, не код чужого репозитория.
|
||||
// Billing владеет значением статуса.
|
||||
const display = {
|
||||
statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'Открыт',
|
||||
amountMinor: invoice.amountMinor,
|
||||
currencyCode: invoice.currencyCode,
|
||||
};
|
||||
|
||||
// Общая утилита получает только форматируемые primitive values.
|
||||
const amountLabel = formatMoney({
|
||||
amountMinor: display.amountMinor,
|
||||
currencyCode: display.currencyCode,
|
||||
locale: 'ru-RU',
|
||||
});`),
|
||||
h2('Fixed fixture проверяет классификацию, не реальные файлы'),
|
||||
p('Fixture для этой статьи хранит три графа как frozen JS records: clean root API, utility-to-domain edge и consumer-to-internal edge. На вход он принимает только marked case id и возвращает `compliant` или `violated` лишь для записанного synthetic случая. Далее decision draft предлагает удалить ровно найденный edge или сохранить public API record. Это полезно для модели: можно проверить, что domain leak не маскируется под deep import и что неизвестный case, попытка передать `repositoryPath` или режим `scan-project` отклоняются.'),
|
||||
code(fixtureCommand),
|
||||
p('Отчёт fixture прямо возвращает `realRepository=not-read`, `realImportGraph=not-scanned`, `realLint=not-run`, `realCi=not-run` и `productionEffect=not-attempted`. Это не оговорка мелким шрифтом. Она не даёт принять synthetic PASS за доказательство качества текущего монорепозитория. Чтобы проверить настоящий проект, нужны согласованная область, разрешение на чтение, выбранный parser/resolver, зафиксированная toolchain и отдельный результат review.'),
|
||||
h2('Симптом → причина → проверка → действие в механике'),
|
||||
ol([
|
||||
'<strong>Симптом.</strong> Новый consumer импортирует `/internal`, либо shared package импортирует business type, и это кажется быстрым способом избежать adapter-а.',
|
||||
'<strong>Причина.</strong> Публичная поверхность не названа; runtime visibility, type resolution и team policy были приняты за один и тот же механизм.',
|
||||
'<strong>Проверка.</strong> Сравните каждый edge с root API record: кто владеет входным типом, разрешён ли specifier, покрывает ли выбранный tool именно такой import syntax и есть ли у запрета смысловая альтернатива.',
|
||||
'<strong>Действие.</strong> Вынесите domain interpretation к owner-у, сузьте export surface, добавьте named static restriction, а исключения оформляйте отдельным adapter/package review.',
|
||||
]),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Контракт не делает все пакеты идеально независимыми. Есть intentional adapters, plugins, generated clients, framework entry points и миграционные периоды. Для них политика должна сказать, кто может пройти границу и как будет удалено исключение. Не используйте `exports` там, где runtime или consumer compatibility этого не поддерживает; не включайте TypeScript mode без проверки emitted output; не выдавайте ESLint diagnostics за анализ dynamic graph. Наконец, boundary rule не заменяет тест поведения public API.'),
|
||||
p('Следующий шаг — взять один import, который сегодня выглядит «почти нормальным», и провести его по пяти колонкам из таблицы. Если это domain fact внутри utility, переведите его в primitive data на стороне domain owner. Если consumerу действительно не хватает функции, не разрешайте deep import: опишите новый root export, owner и compatibility. Так механизм останется небольшим и проверяемым, а не превратится в набор инструментов, которыми никто не управляет.'),
|
||||
h2('Историческая граница марта 2024'),
|
||||
p('Все три источника были доступны к марту 2024: Node v20.11.1, TypeScript 4.7 и ESLint v8.55.0. Статья не приписывает им поздние возможности и не изображает synthetic model проверкой чужого import graph или CI.'),
|
||||
]);
|
||||
|
||||
const field = revision({
|
||||
slug: 'editorial-2024-03-field-package-boundaries',
|
||||
title: 'Границы пакетов: полевой разбор domain leak в общей утилите',
|
||||
categories: ['JavaScript', 'Архитектура'],
|
||||
cover: '/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg',
|
||||
excerpt: 'Учебный полевой кейс: utility получает InvoiceStatus, consumer пробирается в internal cache. Разбираем стоимость, решение, точку проверки и то, чего synthetic fixture принципиально не умеет доказать.',
|
||||
readingMinutes: 12,
|
||||
}, [
|
||||
p('Ситуация учебная, но узнаваемая. Есть `platform-formatting`: его позвали форматировать money и date в нескольких feature. В следующей задаче billing просит показать особую подпись для просроченного счёта. Самый короткий код — импортировать `InvoiceStatus` в formatter. Одновременно orders-feature уже берёт `createFormatterCache` по пути `platform-formatting/internal/...`, потому что root export-а не хватило. Оба решения экономят несколько строк сейчас. Цена — новый enum и новый cache key становятся чужими рисками: billing нельзя менять без formatter-а, formatter нельзя чистить без orders-feature, а owner каждого решения не назван.'),
|
||||
p('Это не отчёт о настоящем сервисе, репозитории или production-инциденте. Все package names, edges и records ниже — заранее записанный synthetic кейс. Его задача — показать порядок разговора, когда общая утилита незаметно начинает быть платформой. Мы не будем утверждать, что нашли зависимости сканером, что измерили bundle или что запустили CI. В такой ситуации полезнее сначала отделить наблюдаемый симптом от вероятной причины, чем выдать красивую диаграмму за доказательство.'),
|
||||
h2('Что именно сломалось в договоре'),
|
||||
p('Первый симптом — utility импортирует доменный `InvoiceStatus`. Это не обязательно создаёт runtime cycle и не обязательно ломает сборку. Но formatting-package получает право решать, какое состояние имеет счёт и как оно называется. Второй симптом — consumer использует internal cache. Это делает файловое устройство package частью contract-а, хотя owner не обещал его поддерживать. Симптомы разные: один переносит доменный смысл вверх, другой расширяет surface вниз. Лечить их одним исключением «разрешить импорт» нельзя.'),
|
||||
p('Причина общая: public API существовал только в головах. Слово «shared» прочитали как «сюда можно всё общее», а слова `internal` и package root не были policy. Поэтому reviewer видит рабочий import и не может ответить на два коротких вопроса: владеет ли источник этим типом и может ли target менять этот путь без миграции. Пока ответ не записан, каждое следующее удобное использование выглядит равноправным с настоящим API.'),
|
||||
table('Карта учебного кейса', ['Наблюдение', 'Что оно означает', 'Кто должен решить', 'Первое безопасное действие'], [
|
||||
['formatter импортирует `InvoiceStatus`', 'доменная интерпретация вошла в platform utility', 'owner billing и owner formatting', 'вернуть label/status mapping в billing, оставить formatter primitive inputs'],
|
||||
['orders-feature импортирует `/internal/formatter-cache`', 'consumer зависим от внутреннего устройства', 'owner formatting и consumer owner', 'проверить, нужен ли root export или consumer хранит cache сам'],
|
||||
['нет списка public names', 'невозможно отличить контракт от файла', 'owner formatting', 'создать one-page record с root specifier и exports'],
|
||||
['lint исключение предлагается до решения', 'инструмент маскирует неясную архитектуру', 'reviewer правила', 'сначала согласовать route, потом настроить статический guard'],
|
||||
['неизвестна совместимость runtime', 'export map может расходиться с consumer tooling', 'package owner', 'проверить поддерживаемые Node/TypeScript/bundler режимы отдельно'],
|
||||
]),
|
||||
figure('/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg', 'Маршрут учебного разбора: симптом «domain type в utility» ведёт к причине «не назван public API», затем к проверке фиксированного edge и действию «вернуть смысл domain owner-у, оставить root API и зафиксировать запрет». Красная ветка deep import останавливается до internal subpath.', 'Диаграмма — decision route для synthetic кейса. Она не показывает историю коммитов, реальные пакеты, CI jobs или зависимости какого-либо приложения.'),
|
||||
h2('Разделить два решения, а не один файл'),
|
||||
p('В billing остаётся выбор статуса и текста. Если он нужен UI, billing может отдать `statusLabel` или более строгую display model, но именно owner billing меняет её при появлении нового enum. Formatting получает `amountMinor`, `currencyCode`, `locale` и возвращает строку. Это не «примитивы ради примитивов». Это минимальный набор, который формирует деньги без знания, почему именно эта сумма показана и какое юридическое состояние у документа.'),
|
||||
p('Для internal cache есть два возможных исхода. Первый: cache — деталь formatter-а; consumer перестаёт его импортировать и вызывает public `formatMoney`. Второй: cache действительно нужен нескольким consumer-ам и имеет стабильную семантику. Тогда он не становится public случайно. Owner описывает отдельный export, входы, lifetime, invalidation и compatibility. В synthetic кейсе мы не выбираем между этими вариантами за реальную команду. Мы фиксируем, что deep import — сигнал к review, а не доказательство, что любой internal helper надо экспортировать.'),
|
||||
h2('Короткий API record для разговора'),
|
||||
p('На одной странице достаточно пяти полей. `Owner`: команда или роль, принимающая изменения surface. `Root specifier`: один путь, который можно импортировать. `Public names`: `formatMoney`, `formatIsoDate`. `Forbidden routes`: `internal/*` для consumers и domain packages для utility. `Evidence`: какой tool и какой review подтверждают конкретное правило. Важно добавить срок пересмотра: иначе migration adapter, появившийся на неделю, станет вечной архитектурой.'),
|
||||
code(`// Только synthetic illustration. Это не конфигурация реального repo.
|
||||
const packageBoundaryRecord = {
|
||||
packageName: '@synthetic/platform-formatting',
|
||||
publicSpecifier: '@synthetic/platform-formatting',
|
||||
publicNames: ['formatMoney', 'formatIsoDate'],
|
||||
forbiddenConsumerRoutes: ['@synthetic/platform-formatting/internal/*'],
|
||||
forbiddenUtilityTargets: ['@synthetic/billing-domain/*'],
|
||||
owner: 'synthetic-formatting-owner',
|
||||
reviewBy: 'human-review-required',
|
||||
};
|
||||
|
||||
// InvoiceStatus остаётся у synthetic billing owner.
|
||||
// Formatter принимает amountMinor, currencyCode и locale.`),
|
||||
h2('Проверка: не путать evidence с догадкой'),
|
||||
p('Для учебного кейса fixture содержит три неизменяемых набора edge: `fixed-clean-public-api`, `fixed-utility-imports-domain` и `fixed-consumer-deep-import`. Он принимает только case id, явно отклоняет `repositoryPath`, `scan-project`, чужой scope и неизвестный case. Report и decision draft обязаны иметь точный набор полей и совпасть с канонической fixed записью; лишнее поле, подменённое action или разрежённый список действий не дают вызвать даже учебный rollback. В положительном случае он возвращает root API с двумя именами и отдельно перечисляет запрещённые consumer subpath. В двух отрицательных случаях он возвращает разные codes: `utility-imports-domain` и `consumer-bypasses-public-api`.'),
|
||||
code(fixtureCommand),
|
||||
h2('Как проходит review решения'),
|
||||
ol([
|
||||
'<strong>Симптом.</strong> Зафиксируйте exact specifier и imported name из одной заявки или diff. Не расширяйте проблему словами «всё связано со всем».',
|
||||
'<strong>Причина.</strong> Спросите: это domain meaning в utility или consumer зависится от package internals? Возможно, одновременно присутствуют обе причины, но они остаются разными карточками работы.',
|
||||
'<strong>Проверка.</strong> Сверьте edge с API record, его owner, allowed direction, Node/TypeScript compatibility и ограничением выбранного static rule. Для legacy пути отдельно назовите срок существования adapter-а.',
|
||||
'<strong>Действие.</strong> Выберите один из явно названных выходов: вернуть решение domain owner-у; добавить reviewed root export; создать named adapter; отклонить запрос. Зафиксируйте, кто проверит removal исключения.',
|
||||
'<strong>Повторная проверка.</strong> После изменения подтвердите public API и реальную toolchain в пределах согласованной области. Не заменяйте эту работу synthetic fixture-ом.',
|
||||
]),
|
||||
h2('Где команды обычно теряют время'),
|
||||
p('Первый тупик — спор о слове «платформа». Не требуется сперва создать отдельную platform team. Достаточно признать, что общий package уже имеет consumers и изменение его surface имеет цену. Второй — запретить всё через glob. Это даёт ложные violations у adapters и подталкивает к suppressions. Третий — объявить любой new export ошибкой. Иногда public API действительно растёт; важно, чтобы рост имел owner, migration и обратимую границу, а не происходил как побочный эффект internal import.'),
|
||||
p('Четвёртый тупик — надеяться, что TypeScript type check подтверждает смысл dependency. Compiler правильно проверяет формы модулей и типы, но не знает, кто владеет `InvoiceStatus`. Пятый — считать package `exports` непробиваемой стеной. Node прямо ограничивает такую интерпретацию: encapsulation не является сильной защитой от прямого абсолютного обращения к файлу. И наконец, ESLint имеет область действия static imports; если проект использует dynamic loading или generated layers, это следует признать в policy и проверить отдельным способом.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Кейс не даёт готовую структуру папок, не доказывает производительность, не выбирает versioning scheme и не описывает permission model для всех команд. Node, TypeScript и ESLint решают разные части задачи и зависят от конкретных версий, runtime и build pipeline. Внешний package может иметь ещё более строгие semver-обязательства; внутренний package может жить в migration периоде. Любая реальная проверка boundary должна начинаться с разрешённой области чтения и явного списка инструментов, а не с догадки по именам каталогов.'),
|
||||
p('Следующий шаг — провести такой review для одной реальной связи, не для всего монорепозитория. Договоритесь об owner-е, root public API, forbidden route, способе проверять static import и сроке, когда повторите выбор. Если конкретного route пока нет, не добавляйте глобальный запрет ради диаграммы. Если route уже есть, не оставляйте его «временно» без даты. Такая дисциплина не делает систему неподвижной; она делает цену следующей зависимости видимой заранее.'),
|
||||
h2('Историческая граница марта 2024'),
|
||||
p('К марту 2024 Node.js уже поддерживал package `exports`; TypeScript 4.7 поддерживал Node-oriented `exports`, `imports` и self-reference; ESLint 8.55.0 уже включал расширение `no-restricted-imports`. Эти факты используются только в их заявленных границах. Автор уровня M7 формулирует решение как проверяемый контракт и не изображает synthetic case полевым наблюдением из production.'),
|
||||
]);
|
||||
|
||||
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
|
||||
|
||||
function verifyFixture() {
|
||||
const report = runPackageBoundaryFixture();
|
||||
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
|
||||
if (failed.length) {
|
||||
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const count = Object.keys(report.assertions).length;
|
||||
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
|
||||
}
|
||||
|
||||
if (process.argv.includes('--verify-fixture')) verifyFixture();
|
||||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|
||||
Reference in New Issue
Block a user