From a93936ecbfdf15f9c9e8650c837a97565ff752b8 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 15:43:02 +0300 Subject: [PATCH] revise March 2024 package boundary articles --- editorial/production/README.md | 2 +- editorial/reviews/2024-03-draft.md | 39 ++ web/data/editorial-revisions.mjs | 2 + ...boundaries-2024-forbidden-import-route.svg | 54 ++ .../package-boundaries-2024-package-graph.svg | 50 ++ ...ckage-boundaries-2024-public-api-table.svg | 41 ++ web/scripts/upgrade-2024-03.mjs | 599 ++++++++++++++++++ 7 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2024-03-draft.md create mode 100644 web/public/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg create mode 100644 web/public/assets/editorial/2024/package-boundaries-2024-package-graph.svg create mode 100644 web/public/assets/editorial/2024/package-boundaries-2024-public-api-table.svg create mode 100644 web/scripts/upgrade-2024-03.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index 38ef3a7..fcb42d6 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 220 из 358 созданных материалов. Остальные 138 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 223 из 358 созданных материалов. Остальные 135 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2024-03-draft.md b/editorial/reviews/2024-03-draft.md new file mode 100644 index 0000000..053a5df --- /dev/null +++ b/editorial/reviews/2024-03-draft.md @@ -0,0 +1,39 @@ +# P73 · 2024-03 · Границы пакетов — три прохода саморевью + +## Рамка sidecar-пакета + +- Slug: `editorial-2024-03-practice-package-boundaries`, `editorial-2024-03-mechanism-package-boundaries`, `editorial-2024-03-field-package-boundaries`. +- Голос: M7, март 2024. Автор говорит коротко и предметно: сначала цена скрытой зависимости, затем «симптом → причина → проверка → действие», контракт, граница инструмента и следующий шаг. Это не рассказ о личном production-инциденте. +- Созданы ровно пять sidecar-файлов: этот review, import-safe script и три локальные SVG. Overlay, README, `articles.json`, очередь, Git, staging, commit/push и чужие файлы не менялись; пакет не интегрирован. +- Fixture хранит только fixed synthetic JS records в памяти Node. Он не читает repository, source files, package.json, tsconfig, eslint config, lockfile, environment, часы, CI output, сеть, HTTP, SDK или реальный import graph. Его PASS не доказывает существование пакетов, dependency graph, lint result, bundle, release или production effect. + +## Проход 1 — факты, источники и модель + +- Источники перепроверены 31.07.2026 и существовали к марту 2024. [Node.js v20.11.1: Modules: Packages](https://nodejs.org/download/release/v20.11.1/docs/api/packages.html) документирует `exports`: entry points package import-а можно задать явно, а неэкспортируемые subpath становятся недоступны обычному package import. Там же явно сказано, что это не strong encapsulation против прямого абсолютного пути. Поэтому статья не называет `exports` security boundary и не утверждает, что оно строит архитектуру монорепозитория. +- [TypeScript 4.7 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7) опубликованы в мае 2022: `node16` и `nodenext` поддерживают Node-oriented ESM/CJS resolution, package.json `exports`, `imports` и self-reference. В статьях это ограничено корректностью module/type resolution; TypeScript не объявлен средством решить, имеет ли utility право знать `InvoiceStatus`. +- [ESLint v8.55.0 release notes, 01.12.2023](https://eslint.org/blog/2023/12/eslint-v8.55.0-released/) подтверждают доступность `importNamePattern` в `no-restricted-imports` до марта 2024. Это используется как пример статического route guard после решения команды, а не как обещание полного dependency graph, обработки dynamic import или business-semantics analysis. +- `inspectSyntheticPackageBoundary()` принимает только input v1 c marker `synthetic`, scope `synthetic-package-boundary-demo`, режимом `fixed-memory-only` и одним из трёх embedded case id. Она отвергает non-synthetic input, лишнее project-like поле, чужой scope, `scan-project` и неизвестный case. Модель не получает произвольный graph из файлов или сети. +- В трёх фиксированных записях различены: чистый root import, `utility-imports-domain` (`platform-formatting → billing-domain/InvoiceStatus`) и `consumer-bypasses-public-api` (consumer → `platform-formatting/internal/*`). Это две разные причины с разными draft action, а не один размытый verdict «плохая архитектура». +- `planSyntheticBoundaryRemediation()` принимает только отчёт fixture, повторно сверяет его с fixed record и возвращает лишь decision draft. Он не пишет package.json или lint config, не запускает lint/CI и возвращает `realConfiguration=not-written`, `realLint=not-run`, `realCi=not-run`, `productionEffect=not-attempted`. `rollbackSyntheticBoundaryDraft()` удаляет только этот synthetic draft и не трогает repository, files, network или CI. +- На техническом ревью найдены и исправлены границы модели. `inspect` требует точный набор own-полей, а не только отсутствие незнакомых; report и plan требуют полную ожидаемую форму и плотные массивы. `plan` сравнивает полный canonical report с fixed record, а rollback воспроизводит canonical plan для case id и отвергает лишнее поле, подменённый `draftAction` и разрежённый список действий. Это сохраняет связь результата и embedded case, не превращая fixture в доверчивый parser произвольных данных. + +## Проход 2 — редактура, объём и голос + +- Три материала отвечают на разные вопросы. Practice строит минимальный контракт shared utility и порядок миграции. Mechanism различает public API record, Node `exports`, TypeScript resolution и static route guard. Field разбирает один synthetic case, где domain type попал в utility, а consumer обошёл root API через `internal`. +- В первых двух абзацах каждого текста названы ситуация и цена: скрытая доменная связность расширяет поверхность изменения, делает enum и internal cache чужими рисками, повышает стоимость review, миграции и обратного хода. Ни одна статья не придумывает реальный production-инцидент, bundle measurement, scan или запуск CI. +- В каждой ревизии есть figure с meaningful `alt` и подписью, доступная таблица, исполнимый synthetic example, порядок «симптом → причина → проверка → действие», явные ограничения и конкретный следующий шаг. Public API раскрыт как specifier + names + input/output + owner, а запрещённый импорт — как маршрут и правило, а не общий лозунг. +- `audit:draft` фиксирует основной текст без списка источников: practice — 9 960, mechanism — 9 999, field — 9 969 знаков. Все три текста попадают в целевой коридор 8–10 тыс. и обязательный диапазон 5–15 тыс. знаков. +- Речь вычитана против шаблонных оценок. Термины `public API`, `root specifier`, `exports`, `node16`, `internal subpath`, `static import`, `domain owner` либо привязаны к примеру, либо ограничены источником. Нет обещания универсальной архитектуры, «магической» изоляции и подмены прогона fixture реальной проверкой проекта. + +## Проход 3 — визуал, безопасность и выпуск + +- SVG разделяют три задачи: направление dependency edge, границу public API и маршрут принятия решения. В них нет `script`, `foreignObject`, внешних URL, `data:image`, event-handler-атрибутов или пользовательского ввода. +- Для мобильного чтения схемы выполнен Sharp-render шириной 375 px и ручное открытие каждого PNG. Крупные карточки, контрастные подписи, короткие стрелки и отдельная нижняя строка ограничения остаются читаемы; элементы не перекрываются и не обрезаются. Визуал не выдаёт synthetic package names за сведения о реальном проекте. +- Финальные проверки: `node --check web/scripts/upgrade-2024-03.mjs` — PASS; fixture — PASS 24/24; `cd web && npm run audit:draft -- scripts/upgrade-2024-03.mjs` — PASS для трёх slug; import-safe export без `date`/`author` — PASS; `xmllint --noout` всех трёх SVG — PASS; SVG safety scan — чисто (нет `script`, `foreignObject`, external URL, `data:image` или event handler); Sharp-render 375 px и ручной просмотр — PASS. +- До интеграции пакет не меняет overlay, README, `articles.json`, очередь, Git или чужие файлы. Реальная проверка import graph и настройка production tooling находятся за границей P73. + +## Выпуск после трёх проходов + +- Главный редактор подключил ровно три мартовские ревизии в `web/data/editorial-revisions.mjs`, не меняя архивный `articles.json`, и обновил счётчик производства до 223 из 358 материалов. +- После подключения `npm run audit:articles -- <три slug>` подтвердил объём, figure, таблицу и пример каждой статьи; registry содержит 214 уникальных ревизий без повторов slug. +- `npm run build` завершился успешно: Next.js сгенерировал 374 статические страницы. В выпуск не включены пользовательские правки и sidecar-пакеты апреля, мая, июня и июля. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index c428059..5341d77 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -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, ]; diff --git a/web/public/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg b/web/public/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg new file mode 100644 index 0000000..6a91ba5 --- /dev/null +++ b/web/public/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg @@ -0,0 +1,54 @@ + + Маршрут разбора запрещённого импорта + Четыре шага ведут от симптома domain type в utility к причине неописанного public API, проверке фиксированного synthetic edge и действию: вернуть смысл доменному owner-у, оставить root API и зафиксировать route. Красная стрелка показывает, что deep import в internal subpath останавливают до изменения. + + + Маршрут: симптом → причина → проверка → действие + Разбор одного fixed synthetic edge до конфигурации настоящего инструмента + + + 1. Симптом + utility imports + InvoiceStatus + или consumer идёт + в internal subpath + + + 2. Причина + не названы root + API, owner и + запрещённые + направления + + + 3. Проверка + fixed synthetic + edge сверяется + с API record + не repository scan + + + 4. Действие + domain meaning + → domain owner + consumer → root + API or review + + + + + + + + Стоп: internal import не становится API молча + Нужен root export review или удаление зависимости. + + + Результат fixture — decision draft only: real files, lint, CI, network и production не затрагиваются. + + + + + + + diff --git a/web/public/assets/editorial/2024/package-boundaries-2024-package-graph.svg b/web/public/assets/editorial/2024/package-boundaries-2024-package-graph.svg new file mode 100644 index 0000000..1191011 --- /dev/null +++ b/web/public/assets/editorial/2024/package-boundaries-2024-package-graph.svg @@ -0,0 +1,50 @@ + + Граф границ пакета platform formatting + Orders feature и billing domain используют root API formatting package. Formatting package использует platform runtime. Пунктирная красная стрелка от formatting package к billing domain запрещена: utility не должна импортировать доменную модель. + + + Граница shared utility: направление зависимостей + Учебная схема: public API меньше внутреннего устройства пакета + + + orders feature + consumer + formatMoney from root API + + + billing domain + owner InvoiceStatus + may use root API + + + platform formatting + public: formatMoney + public: formatIsoDate + inputs: primitive values only + + + platform + runtime + Intl adapter + + + root import + + root import + + allowed runtime + + + + запрещено: utility → domain + InvoiceStatus остаётся у billing owner + + + Это fixed synthetic diagram: не import scan, не package.json и не production evidence. + + + + + + + diff --git a/web/public/assets/editorial/2024/package-boundaries-2024-public-api-table.svg b/web/public/assets/editorial/2024/package-boundaries-2024-public-api-table.svg new file mode 100644 index 0000000..5b43d0b --- /dev/null +++ b/web/public/assets/editorial/2024/package-boundaries-2024-public-api-table.svg @@ -0,0 +1,41 @@ + + Таблица публичного API formatting package + В зелёной колонке указаны root package specifier, formatMoney и formatIsoDate с примитивными входами. В красной колонке — запрещённые InvoiceStatus и internal subpath. Нижняя полоса поясняет, что public API является контрактом, а не каталогом файлов. + + + Public API: обещание, а не каталог файлов + Одна utility знает формат, но не доменную модель счёта + + + + Разрешённый public API + specifier + @synthetic/platform-formatting + + names + formatMoney + formatIsoDate + + formatMoney input → output + amountMinor, currencyCode, locale + → formatted string + owner reviews every new root export + + + + Не является public API + domain model + InvoiceStatus + utility не интерпретирует статус счёта + + consumer deep import + platform-formatting/internal/* + file layout is not a stable promise + + utility outgoing route + → @synthetic/billing-domain + requires a separate adapter or domain owner + + + Public API = specifier + names + input/output + owner. Это не реальная export map и не lint report. + diff --git a/web/scripts/upgrade-2024-03.mjs b/web/scripts/upgrade-2024-03.mjs new file mode 100644 index 0000000..8e90a6c --- /dev/null +++ b/web/scripts/upgrade-2024-03.mjs @@ -0,0 +1,599 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const p = (text) => '

' + text + '

'; +const h2 = (text) => '

' + text + '

'; +const code = (text) => '
' + escapeHtml(text) + '
'; +const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; +const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; + +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>[\s\S]*?(?=

|$)/, '')); +} + +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 ''; +} + +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([ + 'Симптом. В описании задачи звучит «добавим одно условие в shared helper», а имя импортируемого объекта относится к заказу, счету, клиенту или другому домену.', + 'Причина. В utility нет записанного public API. Потребитель видит файлы и считает любой внутренний symbol доступным; домен видит свободную функцию и переносит в неё собственное решение.', + 'Проверка. Для одного пакета перечислите root specifier, экспортируемые имена, входы, выход и два запрещённых направления: consumer не ходит в `/internal`, utility не импортирует domain package.', + 'Действие. Оставьте в 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([ + 'Симптом. Новый consumer импортирует `/internal`, либо shared package импортирует business type, и это кажется быстрым способом избежать adapter-а.', + 'Причина. Публичная поверхность не названа; runtime visibility, type resolution и team policy были приняты за один и тот же механизм.', + 'Проверка. Сравните каждый edge с root API record: кто владеет входным типом, разрешён ли specifier, покрывает ли выбранный tool именно такой import syntax и есть ли у запрета смысловая альтернатива.', + 'Действие. Вынесите 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([ + 'Симптом. Зафиксируйте exact specifier и imported name из одной заявки или diff. Не расширяйте проблему словами «всё связано со всем».', + 'Причина. Спросите: это domain meaning в utility или consumer зависится от package internals? Возможно, одновременно присутствуют обе причины, но они остаются разными карточками работы.', + 'Проверка. Сверьте edge с API record, его owner, allowed direction, Node/TypeScript compatibility и ограничением выбранного static rule. Для legacy пути отдельно назовите срок существования adapter-а.', + 'Действие. Выберите один из явно названных выходов: вернуть решение domain owner-у; добавить reviewed root export; создать named adapter; отклонить запрос. Зафиксируйте, кто проверит removal исключения.', + 'Повторная проверка. После изменения подтвердите 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');