Files
progcode/web/scripts/upgrade-2024-03.mjs
huncode a93936ecbf
Build and deploy / deploy (push) Successful in 16s
revise March 2024 package boundary articles
2026-07-31 15:43:02 +03:00

600 lines
71 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const 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('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.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');