600 lines
71 KiB
JavaScript
600 lines
71 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
const p = (text) => '<p>' + text + '</p>';
|
||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||
|
||
function plainText(content) {
|
||
return content
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function bodyText(content) {
|
||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
const sources = [
|
||
{
|
||
title: 'Node.js v20.11.1: Modules: Packages, февраль 2024',
|
||
url: 'https://nodejs.org/download/release/v20.11.1/docs/api/packages.html',
|
||
note: 'Первичная документация Node.js, доступная до марта 2024. Поле package.json "exports" задаёт доступные entry points; неэкспортируемые subpath для обычного package import недоступны. Node отдельно оговаривает, что это не сильная изоляция против прямого абсолютного пути. Документ не рисует архитектуру конкретного монорепозитория и не заменяет правило команды.',
|
||
},
|
||
{
|
||
title: 'TypeScript 4.7: ECMAScript Module Support in Node.js, май 2022',
|
||
url: 'https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7',
|
||
note: 'Первичные release notes TypeScript. В режимах node16 и nodenext TypeScript поддерживает package.json "exports", "imports" и self-reference, а также различает ESM/CJS entry points и декларации. Это описание поведения компилятора, а не политика допустимых зависимостей между доменами.',
|
||
},
|
||
{
|
||
title: 'ESLint v8.55.0 release notes, 01.12.2023',
|
||
url: 'https://eslint.org/blog/2023/12/eslint-v8.55.0-released/',
|
||
note: 'Первичный релиз ESLint, опубликованный до марта 2024: rule no-restricted-imports получила option importNamePattern. Она помогает фиксировать статические запреты импорта, но сама по себе не доказывает архитектуру, не строит полный dependency graph и не заменяет review динамических загрузок.',
|
||
},
|
||
];
|
||
|
||
function sourceList() {
|
||
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function revision(meta, parts) {
|
||
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
|
||
const proseLength = bodyText(contentHtml).length;
|
||
if (proseLength < 5000 || proseLength > 15000) {
|
||
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
|
||
}
|
||
return Object.freeze({ ...meta, contentHtml, proseLength });
|
||
}
|
||
|
||
const MODEL_LIMIT = 'fixed-in-memory-synthetic-package-boundary-model-no-repository-read-no-files-no-network-no-ci-no-import-graph-scan-no-production-change';
|
||
const DEMO_SCOPE = 'synthetic-package-boundary-demo';
|
||
const UTILITY_PACKAGE = '@synthetic/platform-formatting';
|
||
const BILLING_PACKAGE = '@synthetic/billing-domain';
|
||
const ORDERS_PACKAGE = '@synthetic/orders-feature';
|
||
const RUNTIME_PACKAGE = '@synthetic/intl-runtime';
|
||
|
||
const PUBLIC_API = Object.freeze({
|
||
packageName: UTILITY_PACKAGE,
|
||
rootSpecifier: UTILITY_PACKAGE,
|
||
exports: Object.freeze([
|
||
Object.freeze({ name: 'formatMoney', input: 'amountMinor,currencyCode,locale', output: 'string' }),
|
||
Object.freeze({ name: 'formatIsoDate', input: 'isoDate,locale', output: 'string' }),
|
||
]),
|
||
forbiddenConsumerSubpaths: Object.freeze([
|
||
UTILITY_PACKAGE + '/internal/*',
|
||
UTILITY_PACKAGE + '/src/*',
|
||
]),
|
||
forbiddenUtilityTargets: Object.freeze([
|
||
BILLING_PACKAGE,
|
||
'@synthetic/account-domain',
|
||
]),
|
||
});
|
||
|
||
const fixedCases = Object.freeze({
|
||
'fixed-clean-public-api': Object.freeze({
|
||
label: 'consumer uses only the public formatting API',
|
||
edges: Object.freeze([
|
||
Object.freeze({ from: ORDERS_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatMoney', relation: 'public-api' }),
|
||
Object.freeze({ from: BILLING_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatIsoDate', relation: 'public-api' }),
|
||
Object.freeze({ from: UTILITY_PACKAGE, to: RUNTIME_PACKAGE, specifier: RUNTIME_PACKAGE, importName: 'createFormatter', relation: 'allowed-platform-runtime' }),
|
||
]),
|
||
}),
|
||
'fixed-utility-imports-domain': Object.freeze({
|
||
label: 'utility imports a billing domain type',
|
||
edges: Object.freeze([
|
||
Object.freeze({ from: ORDERS_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatMoney', relation: 'public-api' }),
|
||
Object.freeze({ from: UTILITY_PACKAGE, to: BILLING_PACKAGE, specifier: BILLING_PACKAGE + '/invoice-state', importName: 'InvoiceStatus', relation: 'domain-leak' }),
|
||
Object.freeze({ from: UTILITY_PACKAGE, to: RUNTIME_PACKAGE, specifier: RUNTIME_PACKAGE, importName: 'createFormatter', relation: 'allowed-platform-runtime' }),
|
||
]),
|
||
}),
|
||
'fixed-consumer-deep-import': Object.freeze({
|
||
label: 'consumer bypasses public formatting API',
|
||
edges: Object.freeze([
|
||
Object.freeze({ from: ORDERS_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE + '/internal/formatter-cache', importName: 'createFormatterCache', relation: 'deep-import' }),
|
||
Object.freeze({ from: BILLING_PACKAGE, to: UTILITY_PACKAGE, specifier: UTILITY_PACKAGE, importName: 'formatIsoDate', relation: 'public-api' }),
|
||
Object.freeze({ from: UTILITY_PACKAGE, to: RUNTIME_PACKAGE, specifier: RUNTIME_PACKAGE, importName: 'createFormatter', relation: 'allowed-platform-runtime' }),
|
||
]),
|
||
}),
|
||
});
|
||
|
||
function hasExactKeys(value, keys) {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||
const prototype = Object.getPrototypeOf(value);
|
||
return (prototype === Object.prototype || prototype === null)
|
||
&& Object.keys(value).length === keys.length
|
||
&& keys.every((key) => Object.hasOwn(value, key));
|
||
}
|
||
|
||
function hasDenseArray(value) {
|
||
return Array.isArray(value)
|
||
&& Object.keys(value).length === value.length
|
||
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
|
||
}
|
||
|
||
function hasSameCanonicalJson(actual, expected) {
|
||
try {
|
||
return JSON.stringify(actual) === JSON.stringify(expected);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function rejectBoundary(reason) {
|
||
return Object.freeze({
|
||
kind: 'synthetic-package-boundary-report-v1',
|
||
syntheticOnly: true,
|
||
accepted: false,
|
||
reason,
|
||
modelLimit: MODEL_LIMIT,
|
||
});
|
||
}
|
||
|
||
export function createFixedSyntheticBoundaryInput(caseId) {
|
||
if (!Object.hasOwn(fixedCases, caseId)) {
|
||
return Object.freeze({ synthetic: false, kind: 'unknown-synthetic-package-boundary-input', caseId });
|
||
}
|
||
return Object.freeze({
|
||
synthetic: true,
|
||
kind: 'synthetic-package-boundary-input-v1',
|
||
scope: DEMO_SCOPE,
|
||
mode: 'fixed-memory-only',
|
||
caseId,
|
||
});
|
||
}
|
||
|
||
function violationFor(edge) {
|
||
if (edge.from === UTILITY_PACKAGE && PUBLIC_API.forbiddenUtilityTargets.some((target) => edge.to === target)) {
|
||
return Object.freeze({
|
||
code: 'utility-imports-domain',
|
||
edge,
|
||
rule: UTILITY_PACKAGE + ' may use only primitive formatting inputs and approved platform runtime; it may not import domain packages.',
|
||
draftAction: 'move InvoiceStatus interpretation back to ' + BILLING_PACKAGE + ' and pass amountMinor,currencyCode,locale to formatMoney',
|
||
});
|
||
}
|
||
if (edge.to === UTILITY_PACKAGE && edge.specifier !== PUBLIC_API.rootSpecifier) {
|
||
return Object.freeze({
|
||
code: 'consumer-bypasses-public-api',
|
||
edge,
|
||
rule: 'consumers import only ' + PUBLIC_API.rootSpecifier + '; internal and src subpaths are not public API.',
|
||
draftAction: 'replace the deep import with a documented root export or add a deliberately reviewed public export',
|
||
});
|
||
}
|
||
if (edge.to === UTILITY_PACKAGE && !PUBLIC_API.exports.some((entry) => entry.name === edge.importName)) {
|
||
return Object.freeze({
|
||
code: 'consumer-uses-unknown-public-symbol',
|
||
edge,
|
||
rule: 'the root entry point exposes only the reviewed names in the synthetic public API.',
|
||
draftAction: 'choose formatMoney or formatIsoDate, or open a separate API review',
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Inspects one of three fixed synthetic import records embedded above. It does
|
||
* not read a repository, source file, configuration, environment variable,
|
||
* clock, package manager, CI output, network, HTTP endpoint or real import
|
||
* graph. "compliant" and "violated" are facts only about this tiny example.
|
||
*/
|
||
export function inspectSyntheticPackageBoundary(input) {
|
||
if (!input || input.synthetic !== true || input.kind !== 'synthetic-package-boundary-input-v1') {
|
||
return rejectBoundary('synthetic-input-required');
|
||
}
|
||
const allowed = ['synthetic', 'kind', 'scope', 'mode', 'caseId'];
|
||
if (!hasExactKeys(input, allowed)) return rejectBoundary('unexpected-input-field');
|
||
if (input.scope !== DEMO_SCOPE) return rejectBoundary('unexpected-synthetic-scope');
|
||
if (input.mode !== 'fixed-memory-only') return rejectBoundary('synthetic-mode-required');
|
||
if (!Object.hasOwn(fixedCases, input.caseId)) return rejectBoundary('unknown-fixed-synthetic-case');
|
||
|
||
const fixed = fixedCases[input.caseId];
|
||
const violations = fixed.edges.map(violationFor).filter(Boolean);
|
||
const status = violations.length === 0 ? 'compliant' : 'violated';
|
||
const publicApi = Object.freeze({
|
||
packageName: PUBLIC_API.packageName,
|
||
rootSpecifier: PUBLIC_API.rootSpecifier,
|
||
exports: PUBLIC_API.exports,
|
||
forbiddenConsumerSubpaths: PUBLIC_API.forbiddenConsumerSubpaths,
|
||
});
|
||
|
||
return Object.freeze({
|
||
kind: 'synthetic-package-boundary-report-v1',
|
||
syntheticOnly: true,
|
||
accepted: true,
|
||
reason: 'fixed-synthetic-case-inspected',
|
||
modelLimit: MODEL_LIMIT,
|
||
scope: DEMO_SCOPE,
|
||
caseId: input.caseId,
|
||
caseLabel: fixed.label,
|
||
status,
|
||
publicApi,
|
||
inspectedEdges: fixed.edges,
|
||
violations: Object.freeze(violations),
|
||
evidence: Object.freeze({ source: 'embedded-fixed-records-only', realRepository: 'not-read', realImportGraph: 'not-scanned' }),
|
||
nextReview: status === 'compliant'
|
||
? 'record-public-api-draft-only'
|
||
: 'review-the-listed-synthetic-edge-before-any-real-change',
|
||
productionEffect: 'not-attempted',
|
||
});
|
||
}
|
||
|
||
function isCanonicalSyntheticBoundaryReport(report) {
|
||
const reportKeys = ['kind', 'syntheticOnly', 'accepted', 'reason', 'modelLimit', 'scope', 'caseId', 'caseLabel', 'status', 'publicApi', 'inspectedEdges', 'violations', 'evidence', 'nextReview', 'productionEffect'];
|
||
if (!hasExactKeys(report, reportKeys)
|
||
|| report.kind !== 'synthetic-package-boundary-report-v1'
|
||
|| report.syntheticOnly !== true
|
||
|| report.accepted !== true
|
||
|| report.reason !== 'fixed-synthetic-case-inspected'
|
||
|| report.modelLimit !== MODEL_LIMIT
|
||
|| report.scope !== DEMO_SCOPE
|
||
|| !Object.hasOwn(fixedCases, report.caseId)
|
||
|| report.caseLabel !== fixedCases[report.caseId].label
|
||
|| !['compliant', 'violated'].includes(report.status)
|
||
|| report.productionEffect !== 'not-attempted') return false;
|
||
|
||
return hasExactKeys(report.publicApi, ['packageName', 'rootSpecifier', 'exports', 'forbiddenConsumerSubpaths'])
|
||
&& hasDenseArray(report.publicApi.exports)
|
||
&& hasDenseArray(report.publicApi.forbiddenConsumerSubpaths)
|
||
&& hasDenseArray(report.inspectedEdges)
|
||
&& hasDenseArray(report.violations)
|
||
&& hasExactKeys(report.evidence, ['source', 'realRepository', 'realImportGraph'])
|
||
&& report.evidence.source === 'embedded-fixed-records-only'
|
||
&& report.evidence.realRepository === 'not-read'
|
||
&& report.evidence.realImportGraph === 'not-scanned';
|
||
}
|
||
|
||
function makeSyntheticBoundaryDecisionDraft(fresh) {
|
||
const actions = fresh.status === 'compliant'
|
||
? Object.freeze(['record-root-public-api', 'retain-forbidden-route-list', 'schedule-human-review'])
|
||
: Object.freeze(fresh.violations.map((violation) => violation.draftAction));
|
||
return Object.freeze({
|
||
accepted: true,
|
||
syntheticOnly: true,
|
||
reason: 'synthetic-boundary-decision-draft',
|
||
scope: DEMO_SCOPE,
|
||
caseId: fresh.caseId,
|
||
status: fresh.status,
|
||
actions,
|
||
lintIdea: 'draft-only: no-restricted-imports may encode named static routes after a team chooses its actual paths',
|
||
packageExportIdea: 'draft-only: package.json exports can declare a root entry point where the runtime and compatibility policy permit it',
|
||
realConfiguration: 'not-written',
|
||
realLint: 'not-run',
|
||
realCi: 'not-run',
|
||
productionEffect: 'not-attempted',
|
||
modelLimit: MODEL_LIMIT,
|
||
});
|
||
}
|
||
|
||
function isCanonicalSyntheticBoundaryPlan(plan) {
|
||
const planKeys = ['accepted', 'syntheticOnly', 'reason', 'scope', 'caseId', 'status', 'actions', 'lintIdea', 'packageExportIdea', 'realConfiguration', 'realLint', 'realCi', 'productionEffect', 'modelLimit'];
|
||
if (!hasExactKeys(plan, planKeys)
|
||
|| plan.accepted !== true
|
||
|| plan.syntheticOnly !== true
|
||
|| plan.reason !== 'synthetic-boundary-decision-draft'
|
||
|| plan.scope !== DEMO_SCOPE
|
||
|| !Object.hasOwn(fixedCases, plan.caseId)
|
||
|| !hasDenseArray(plan.actions)
|
||
|| plan.realConfiguration !== 'not-written'
|
||
|| plan.realLint !== 'not-run'
|
||
|| plan.realCi !== 'not-run'
|
||
|| plan.productionEffect !== 'not-attempted'
|
||
|| plan.modelLimit !== MODEL_LIMIT) return false;
|
||
|
||
const fresh = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput(plan.caseId));
|
||
return fresh.accepted === true && hasSameCanonicalJson(plan, makeSyntheticBoundaryDecisionDraft(fresh));
|
||
}
|
||
|
||
/**
|
||
* Returns a decision draft only for a report produced by this fixture. It does
|
||
* not create package.json, lint config, source code, a pull request or a CI
|
||
* check. The plan is deliberately data, not an operational command.
|
||
*/
|
||
export function planSyntheticBoundaryRemediation(report) {
|
||
if (!report || report.kind !== 'synthetic-package-boundary-report-v1' || report.syntheticOnly !== true || report.accepted !== true) {
|
||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelLimit: MODEL_LIMIT });
|
||
}
|
||
if (!isCanonicalSyntheticBoundaryReport(report)) {
|
||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'untrusted-synthetic-report-shape', modelLimit: MODEL_LIMIT });
|
||
}
|
||
const fresh = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput(report.caseId));
|
||
if (!hasSameCanonicalJson(report, fresh)) {
|
||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-fixed-record', modelLimit: MODEL_LIMIT });
|
||
}
|
||
return makeSyntheticBoundaryDecisionDraft(fresh);
|
||
}
|
||
|
||
export function rollbackSyntheticBoundaryDraft(plan) {
|
||
if (!isCanonicalSyntheticBoundaryPlan(plan)) {
|
||
return Object.freeze({ restored: false, syntheticOnly: true, reason: 'no-accepted-synthetic-plan' });
|
||
}
|
||
return Object.freeze({
|
||
restored: true,
|
||
syntheticOnly: true,
|
||
reason: 'synthetic-decision-draft-discarded',
|
||
repository: 'not-read-or-changed',
|
||
files: 'not-read-or-written',
|
||
network: 'not-used',
|
||
ci: 'not-run',
|
||
productionEffect: 'not-attempted',
|
||
});
|
||
}
|
||
|
||
export function runPackageBoundaryFixture() {
|
||
const clean = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput('fixed-clean-public-api'));
|
||
const domainLeak = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput('fixed-utility-imports-domain'));
|
||
const deepImport = inspectSyntheticPackageBoundary(createFixedSyntheticBoundaryInput('fixed-consumer-deep-import'));
|
||
const nonSynthetic = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), synthetic: false });
|
||
const unexpectedField = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), repositoryPath: '/not/read' });
|
||
const wrongScope = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), scope: 'other-scope' });
|
||
const wrongMode = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), mode: 'scan-project' });
|
||
const unknownCase = inspectSyntheticPackageBoundary({ ...createFixedSyntheticBoundaryInput('fixed-clean-public-api'), caseId: 'invented-case' });
|
||
const cleanPlan = planSyntheticBoundaryRemediation(clean);
|
||
const domainPlan = planSyntheticBoundaryRemediation(domainLeak);
|
||
const forgedPlan = planSyntheticBoundaryRemediation({ ...clean, status: 'violated' });
|
||
const unexpectedReportShape = planSyntheticBoundaryRemediation({ ...clean, repositoryPath: '/not/read' });
|
||
const forgedViolationPlan = planSyntheticBoundaryRemediation({
|
||
...domainLeak,
|
||
violations: [{ ...domainLeak.violations[0], draftAction: 'write-a-real-config' }],
|
||
});
|
||
const restored = rollbackSyntheticBoundaryDraft(domainPlan);
|
||
const rejectedRestore = rollbackSyntheticBoundaryDraft(domainLeak);
|
||
const forgedRollbackPlan = rollbackSyntheticBoundaryDraft({ ...domainPlan, actions: ['write-a-real-config'] });
|
||
const sparseActions = new Array(1);
|
||
const sparseRollbackPlan = rollbackSyntheticBoundaryDraft({ ...domainPlan, actions: sparseActions });
|
||
|
||
return Object.freeze({
|
||
assertions: Object.freeze({
|
||
acceptsTheFixedCleanCase: clean.accepted === true && clean.status === 'compliant',
|
||
keepsRootOnlyPublicApi: clean.publicApi.rootSpecifier === UTILITY_PACKAGE && clean.publicApi.exports.length === 2 && clean.publicApi.exports[0].name === 'formatMoney',
|
||
keepsForbiddenConsumerSubpathsExplicit: clean.publicApi.forbiddenConsumerSubpaths.includes(UTILITY_PACKAGE + '/internal/*') && clean.publicApi.forbiddenConsumerSubpaths.includes(UTILITY_PACKAGE + '/src/*'),
|
||
recordsOnlyFixedSyntheticEdges: clean.inspectedEdges.length === 3 && clean.evidence.source === 'embedded-fixed-records-only',
|
||
doesNotClaimARealGraph: clean.evidence.realRepository === 'not-read' && clean.evidence.realImportGraph === 'not-scanned' && clean.productionEffect === 'not-attempted',
|
||
findsUtilityDomainLeak: domainLeak.accepted === true && domainLeak.status === 'violated' && domainLeak.violations.length === 1 && domainLeak.violations[0].code === 'utility-imports-domain',
|
||
namesTheDomainLeakRepair: domainLeak.violations[0].draftAction.includes('amountMinor,currencyCode,locale'),
|
||
findsConsumerDeepImport: deepImport.accepted === true && deepImport.status === 'violated' && deepImport.violations[0].code === 'consumer-bypasses-public-api',
|
||
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-input-required',
|
||
rejectsUnexpectedProjectLikeField: unexpectedField.accepted === false && unexpectedField.reason === 'unexpected-input-field',
|
||
rejectsDifferentScope: wrongScope.accepted === false && wrongScope.reason === 'unexpected-synthetic-scope',
|
||
rejectsScanMode: wrongMode.accepted === false && wrongMode.reason === 'synthetic-mode-required',
|
||
rejectsUnknownFixedCase: unknownCase.accepted === false && unknownCase.reason === 'unknown-fixed-synthetic-case',
|
||
createsOnlyDecisionDraftForCleanCase: cleanPlan.accepted === true && cleanPlan.status === 'compliant' && cleanPlan.actions.includes('record-root-public-api'),
|
||
createsOnlyDecisionDraftForViolation: domainPlan.accepted === true && domainPlan.status === 'violated' && domainPlan.actions.length === 1,
|
||
doesNotWriteOrRunOperationalSystems: domainPlan.realConfiguration === 'not-written' && domainPlan.realLint === 'not-run' && domainPlan.realCi === 'not-run' && domainPlan.productionEffect === 'not-attempted',
|
||
rejectsForgedReport: forgedPlan.accepted === false && forgedPlan.reason === 'report-does-not-match-fixed-record',
|
||
rejectsUnexpectedReportShape: unexpectedReportShape.accepted === false && unexpectedReportShape.reason === 'untrusted-synthetic-report-shape',
|
||
rejectsForgedViolationAction: forgedViolationPlan.accepted === false && forgedViolationPlan.reason === 'report-does-not-match-fixed-record',
|
||
rollbackDiscardsOnlySyntheticDraft: restored.restored === true && restored.repository === 'not-read-or-changed' && restored.files === 'not-read-or-written',
|
||
rollbackDoesNotTouchNetworkOrCi: restored.network === 'not-used' && restored.ci === 'not-run' && restored.productionEffect === 'not-attempted',
|
||
rejectedReportCannotRollback: rejectedRestore.restored === false && rejectedRestore.reason === 'no-accepted-synthetic-plan',
|
||
rejectsForgedRollbackPlan: forgedRollbackPlan.restored === false && forgedRollbackPlan.reason === 'no-accepted-synthetic-plan',
|
||
rejectsSparseRollbackActions: sparseRollbackPlan.restored === false && sparseRollbackPlan.reason === 'no-accepted-synthetic-plan',
|
||
}),
|
||
samples: Object.freeze({ clean, domainLeak, deepImport, nonSynthetic, unexpectedField, wrongScope, wrongMode, unknownCase, cleanPlan, domainPlan, forgedPlan, unexpectedReportShape, forgedViolationPlan, restored, rejectedRestore, forgedRollbackPlan, sparseRollbackPlan }),
|
||
});
|
||
}
|
||
|
||
const syntheticExample = `import {
|
||
createFixedSyntheticBoundaryInput,
|
||
inspectSyntheticPackageBoundary,
|
||
planSyntheticBoundaryRemediation,
|
||
runPackageBoundaryFixture,
|
||
} from './upgrade-2024-03.mjs';
|
||
|
||
const report = inspectSyntheticPackageBoundary(
|
||
createFixedSyntheticBoundaryInput('fixed-utility-imports-domain'),
|
||
);
|
||
const draft = planSyntheticBoundaryRemediation(report);
|
||
|
||
if (!Object.values(runPackageBoundaryFixture().assertions).every(Boolean)) {
|
||
throw new Error('synthetic fixture failed');
|
||
}
|
||
|
||
console.log(report.status); // violated
|
||
console.log(draft.realCi); // not-run
|
||
|
||
// Здесь нет чтения репозитория, файлов, сети, CI или реального import graph.`;
|
||
|
||
const fixtureCommand = syntheticExample + '\n\nnode web/scripts/upgrade-2024-03.mjs --verify-fixture\n\n# PASS подтверждает только согласованность fixed synthetic records и отрицательных веток.';
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2024-03-practice-package-boundaries',
|
||
title: 'Границы пакетов: как остановить общую утилиту до того, как она станет платформой',
|
||
categories: ['JavaScript', 'Архитектура'],
|
||
cover: '/assets/editorial/2024/package-boundaries-2024-package-graph.svg',
|
||
excerpt: 'Практический маршрут для случая, когда shared-утилита начинает импортировать доменную модель: короткий public API, запрещённые направления и проверка без легенды о реальном графе зависимостей.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('Симптом обычно появляется в маленьком pull request. В общей утилите форматирования просят учесть `InvoiceStatus`, потому что так удобнее вывести подпись рядом с суммой. Через неделю второй потребитель берёт внутренний cache этой утилиты, а третий ждёт от неё ещё один доменный флаг. Цена не в самом импорте. Пакет, который считали нейтральной функцией, начинает владеть чужим смыслом. Изменение статуса счёта теперь способно затронуть экран заказа, а ремонт formatter-а требует знать правила billing. Стоимость растёт в review, обновлениях и откатах: у команды больше нет малого места, которое можно менять изолированно.'),
|
||
p('Не надо отвечать на это большим переносом директорий. Сначала нужно назвать границу. Общая утилита полезна, пока принимает данные, не интерпретируя доменную историю: minor units, currency code, locale, ISO date. `InvoiceStatus`, лимиты возврата и правило «показывать счёт как просроченный» принадлежат billing-домену. Если utility импортирует тип или enum, чтобы решить, что показать, она становится транзитной точкой доменной политики. Тогда любой новый consumer получает не только функцию, но и скрытое право зависеть от чужого языка.'),
|
||
h2('Симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> В описании задачи звучит «добавим одно условие в shared helper», а имя импортируемого объекта относится к заказу, счету, клиенту или другому домену.',
|
||
'<strong>Причина.</strong> В utility нет записанного public API. Потребитель видит файлы и считает любой внутренний symbol доступным; домен видит свободную функцию и переносит в неё собственное решение.',
|
||
'<strong>Проверка.</strong> Для одного пакета перечислите root specifier, экспортируемые имена, входы, выход и два запрещённых направления: consumer не ходит в `/internal`, utility не импортирует domain package.',
|
||
'<strong>Действие.</strong> Оставьте в utility преобразование примитивных входов, верните доменную интерпретацию в owner-package и запишите запрет рядом с public API. Автоматическую проверку добавляют только после того, как команда согласовала эти слова.',
|
||
]),
|
||
h2('Минимальный public API, а не каталог файлов'),
|
||
p('Public API — это не всё, что случайно экспортируется из исходной папки. Это короткий договор: какой module specifier разрешён, какие имена можно импортировать, что получает функция и что она возвращает. У форматтера API может быть меньше пяти строк. Важна не красота декларации, а отсутствие доменной дырки. `formatMoney({ amountMinor, currencyCode, locale })` получает числа и строковые коды и возвращает строку. Он не принимает `Invoice`, не знает `InvoiceStatus` и не решает, разрешён ли возврат.'),
|
||
p('Такой API заставляет consumer сделать полезную работу у себя. Billing выбирает состояние счёта и вызывает formatter только для денег. Orders-feature тоже может вызвать formatter, но не вынужден таскать billing-модель. Это не запрет на переиспользование. Это разделение ответственности: общая функция владеет представлением примитивов, домен — значением собственных объектов. Если два домена действительно договорились об одном бизнес-правиле, оно должно жить в явно названном общедоменно́м модуле с owner и версией, а не прятаться внутри «utils».') ,
|
||
table('Контракт маленького formatting-пакета в учебном примере', ['Элемент', 'Разрешено', 'Запрещено', 'Почему'], [
|
||
['Specifier потребителя', '`@synthetic/platform-formatting`', '`@synthetic/platform-formatting/internal/*`', 'root entry point остаётся единственной точкой обещания'],
|
||
['Публичные имена', '`formatMoney`, `formatIsoDate`', 'случайные cache и private helper', 'внутренность можно менять без миграции всех consumers'],
|
||
['Вход `formatMoney`', '`amountMinor`, `currencyCode`, `locale`', '`Invoice`, `InvoiceStatus`, правило скидки', 'утилита не получает доменную модель'],
|
||
['Исходящие зависимости utility', 'согласованный runtime formatting', 'billing и account domains', 'домен не протекает в общую платформенную точку'],
|
||
['Решение при нарушении', 'короткий review и новый контракт', 'тихий deep import или перенос enum', 'изменение становится видимым до распространения'],
|
||
]),
|
||
figure('/assets/editorial/2024/package-boundaries-2024-package-graph.svg', 'Схема учебного графа: orders feature и billing domain используют root API platform formatting; utility использует только platform runtime. Красная пунктирная стрелка от utility к billing domain помечена как запрещённая граница.', 'Граф показывает правило направления, а не результат сканирования проекта. Все названия с префиксом synthetic существуют только в памяти учебной модели.'),
|
||
h2('Где поставить реальную границу'),
|
||
p('Начните не с названия папки, а с вопроса: «какой факт должен остаться верным, если billing меняет свою модель?» Для formatter-а ответ прост: он всё ещё умеет превратить amount и currency в отображаемую строку. Если невозможно сформулировать вход без объекта другого домена, значит функция не общая. Её место либо в billing, либо в отдельном пакете, который явно владеет общим словарём и имеет собственный контракт.'),
|
||
p('Полезно записать и запрещённый маршрут, а не только allowed API. Два правила из примера достаточно жёсткие, но понятные: consumer не импортирует `internal` и `src`; platform formatting не импортирует `@synthetic/billing-domain` и `@synthetic/account-domain`. Запрет не утверждает, что любые пакеты обязаны быть изолированы. Он охраняет конкретную роль пакета. Отдельному adapter-у, который намеренно соединяет billing с UI, нужны другое имя, другой owner и собственные допустимые зависимости.'),
|
||
h2('Что можно сделать механизмами Node.js, TypeScript и ESLint'),
|
||
p('У этих инструментов разные полномочия. Node.js `package.json` `exports` умеет объявить доступные entry points package import-а. В документации Node v20.11.1 сказано, что неэкспортируемые subpath становятся недоступны обычному import, но это не сильная изоляция против прямого абсолютного пути. Значит `exports` полезен как runtime/package contract, но не заменяет архитектурный review и не защищает любой способ доступа к файлу.'),
|
||
p('TypeScript 4.7 добавил режимы `node16` и `nodenext`, которые понимают `exports`, `imports` и self-reference. Это важно для совпадения type-checking с форматом package entry point, особенно когда ESM и CJS имеют разные точки входа. Но compiler не знает бизнес-смысл `InvoiceStatus`. Он может подтвердить, что specifier разрешается, но не решает, должен ли formatter зависеть от billing. Такое решение остаётся в контракте пакета.'),
|
||
p('ESLint `no-restricted-imports` подходит для точного статического запрета после согласования пути. К марту 2024 правило уже умело ограничивать import paths, а релиз ESLint 8.55.0 добавил `importNamePattern`. Однако это слой проверки статического синтаксиса, не средство построить достоверный граф всего проекта. Dynamic import, generated code, aliases и runtime resolution требуют отдельно проверять область применимости. Не записывайте в policy обещание, которое выбранный linter не умеет выполнять.'),
|
||
h2('Исполнимый fixed synthetic пример'),
|
||
p('Ниже fixture не открывает package.json и не ходит по каталогам. Внутри модуля уже лежат три фиксированные записи: чистый root import, импорт доменного `InvoiceStatus` самой utility и deep import consumer-а. Функция принимает только case id с явным `synthetic` marker и проверяет правила на этих объектах. Это удобная проверка формы контракта: она показывает, что «domain leak» и «public API bypass» не смешаны в одном общем сообщении.'),
|
||
code(fixtureCommand),
|
||
p('PASS fixture означает только согласованность заранее записанных synthetic records и их отрицательных веток. Он не читает рабочее дерево, не строит import graph, не знает фактического `package.json`, не запускает lint или CI и не меняет production. Это намеренное ограничение. Тест, который называет себя boundary check, но тихо опирается на состояние неизвестного репозитория, плохо объясняет, какие именно правила он подтвердил.'),
|
||
h2('Переход без большой миграции'),
|
||
p('Сначала остановите расширение поверхности. Опубликуйте короткий root API и для новой задачи требуйте один из двух исходов: использовать существующее имя или открыть отдельное API review. Затем выберите один доменный импорт из utility, перенесите интерпретацию обратно в owner-package и добавьте consumer-side adapter, если ему нужны данные в другом виде. После этого объявите `internal` приватным в документации и настройте выбранный механизм контроля только для уже согласованных путей.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Эта схема не измеряет размер bundle, не оценивает циклы, не проверяет семантическую совместимость API и не устанавливает универсальную слоистость. Node `exports` работает в своих runtime и compatibility условиях; TypeScript modes требуют соответствующей конфигурации; ESLint rule охватывает статические import statements в выбранной настройке. Для legacy-кода может понадобиться временный adapter и срок удаления. Для runtime plugin system статический запрет вообще не описывает все связи.'),
|
||
p('Следующий шаг — выбрать один настоящий shared package и оформить one-page boundary record: owner, root specifier, public names, input/output, allowed incoming consumers, forbidden outgoing domains, способ проверки и дата следующего review. Пока такой record не существует, не называйте функцию платформой. Когда он появится, каждый новый импорт станет коротким проверяемым вопросом, а не очередным исключением в общей утилите.'),
|
||
h2('Историческая граница марта 2024'),
|
||
p('К марту 2024 уже были доступны Node `exports` (в Node с 12.7.0; здесь взята официальная документация v20.11.1), TypeScript 4.7 с поддержкой Node-oriented package resolution и ESLint 8.55.0. Материал использует их как строительные блоки, но не приписывает им более поздние возможности. Голос M7 здесь практичный: сначала цена зависимости, затем контракт, ограничение инструмента и воспроизводимая проверка без выдуманного production-опыта.'),
|
||
]);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2024-03-mechanism-package-boundaries',
|
||
title: 'Границы пакетов: public API, запрещённые импорты и три уровня защиты',
|
||
categories: ['JavaScript', 'Архитектура'],
|
||
cover: '/assets/editorial/2024/package-boundaries-2024-public-api-table.svg',
|
||
excerpt: 'Механика границы пакета: чем отличаются public API, runtime exports и статический запрет импорта; как не принять типовую совместимость за право протащить доменную модель в общую утилиту.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('Сбой границы редко выглядит как архитектурный спор. Сначала TypeScript без возражений принимает `import { InvoiceStatus } from "@domain/billing"` в shared formatter. Затем другой пакет использует private helper, потому что autocomplete его нашёл. Оба импорта работают сегодня. Цена появляется позже: новый billing enum вынуждает выпускать utility, а рефакторинг cache требует искать consumers, которых никто не считал частью API. Команда получает связность без владельца и пытается лечить её новым alias или исключением в linter.'),
|
||
p('Причина в том, что слово «граница» смешивает три разные вещи. Public API отвечает, что package обещает consumers. Runtime/package metadata отвечает, какие package entry points разрешает resolver. Static policy отвечает, какие import routes команда считает недопустимыми в данном слое. Если назвать их одним механизмом, появится ложная уверенность: `exports` объявляют архитектуру, TypeScript якобы запрещает домены, а lint якобы знает полный граф. Ни одно из этих утверждений не верно без явного контракта.'),
|
||
h2('Модель: пакет обещает меньше, чем содержит'),
|
||
p('Пакет всегда содержит больше, чем должен обещать. Внутри formatter-а могут быть cache key, locale fallback и вспомогательный adapter. Consumer не должен строить на них зависимость, иначе любая перестройка внутреннего кода становится breaking change. Public API сужает поверхность до root specifier и нескольких имён. Это не попытка спрятать знания от коллег; это способ зафиксировать, какие изменения требуют миграции и какой owner принимает решение о расширении интерфейса.'),
|
||
p('Доменные типы требуют отдельного внимания. TypeScript type-only import может исчезнуть из emitted JavaScript, но архитектурная зависимость остаётся в исходном коде: formatter начинает понимать чужой словарь, а его declarations начинают отражать этот словарь. Поэтому проверка «в bundle нет billing» недостаточна. Вопрос другой: может ли команда изменить billing-модель, не открывая контракт shared package? Если ответ нет, зависимость уже существует, даже если она была type-only.'),
|
||
table('Три слоя одной границы', ['Слой', 'Что он проверяет', 'Чего он не доказывает', 'Практический вывод'], [
|
||
['Public API record', 'допустимые specifier, names, входы, выходы, owner', 'runtime resolution и все реальные imports', 'сначала договоритесь о смысле'],
|
||
['Node package.json `exports`', 'доступные entry points при package import', 'сильную изоляцию от прямого абсолютного пути и доменную политику', 'используйте для package surface при подходящем runtime'],
|
||
['TypeScript node16/nodenext', 'согласованное разрешение `exports`/`imports` и module format', 'право одного домена знать модель другого', 'держите compiler и runtime в одной модели'],
|
||
['ESLint `no-restricted-imports`', 'названные статические import routes и, в нужной версии, pattern rules', 'полный граф, dynamic imports и смысл модели', 'закодируйте уже принятый локальный запрет'],
|
||
['Human review', 'стоит ли новый факт включать в обещание package', 'машинную полноту без наблюдаемого evidence', 'принимает исключение или создаёт отдельный adapter'],
|
||
]),
|
||
figure('/assets/editorial/2024/package-boundaries-2024-public-api-table.svg', 'Таблица public API учебного platform formatting package: root specifier предоставляет formatMoney и formatIsoDate с примитивными входами; в красной зоне находятся InvoiceStatus и internal subpath.', 'Схема отделяет контракт функции от файлового устройства. Она не показывает настоящий package.json и не утверждает, что эти exports опубликованы в каком-либо registry.'),
|
||
h2('Уровень 1: записать контракт до конфигурации'),
|
||
p('Контракт должен быть настолько мал, чтобы reviewer смог прочитать его без поиска по всему репозиторию. Для `@synthetic/platform-formatting` достаточно зафиксировать root specifier, `formatMoney`, `formatIsoDate`, входы и выходы. За пределами остаются `internal` и `src`, доменные types и business decisions. Появление нового export-а — не строка в barrel file, а изменение public surface: нужен owner, потребитель, причина, migration story и версия, если пакет имеет внешних клиентов.'),
|
||
p('Запрет тоже должен быть написан в терминах направлений. «Не импортировать домены» слишком широко: adapter, который по задаче соединяет UI и billing, станет ложным нарушением. Точнее так: package с ролью `platform-formatting` не импортирует `billing-domain` и `account-domain`; потребители этого package не ходят в `platform-formatting/internal/*`; package с ролью billing может импортировать root API formatter-а. У правила появляются адресат, исключение и проверяемый route.'),
|
||
h2('Уровень 2: package metadata не равна архитектуре'),
|
||
p('В Node.js поле `exports` разрешает явно описать main entry point и subpath exports. Официальная документация Node v20.11.1 объясняет, что при наличии `exports` неописанные subpath не доступны обычному `import "package/subpath"`; это делает surface package надёжнее для tools и semver-изменений. Но там же есть важная граница: абсолютный путь к файлу может обойти такую инкапсуляцию. Поэтому `exports` нельзя продавать команде как security boundary или доказательство отсутствия плохих imports.'),
|
||
h2('Уровень 3: статический запрет должен быть узким'),
|
||
p('После контракта можно поставить статический guard. Например, policy для consumer-слоя запрещает `@synthetic/platform-formatting/internal/*` и предлагает root API. Policy для utility запрещает `@synthetic/billing-domain/*` и предлагает вернуть интерпретацию status в billing. ESLint `no-restricted-imports` создан именно для ограничения конкретных static imports; опубликованный 1 декабря 2023 ESLint 8.55.0 добавил `importNamePattern`. Для простого маршрута этого достаточно: error показывает edge и альтернативу, а не абстрактное «нарушение архитектуры».'),
|
||
h2('Пример: от domain type к primitive contract'),
|
||
p('Плохой вариант не обязательно выглядит огромным. Formatter получает `InvoiceStatus`, выбирает текст «Просрочен» и добавляет вид currency. В нём уже два разных вопроса: как интерпретировать состояние счёта и как отобразить деньги. Разделение выглядит скромно: billing переводит status в свою label или display model, а formatter получает amount, currency и locale. Это не делает код безошибочным, но возвращает изменение status в domain package и оставляет utility независимой от его enum.'),
|
||
code(`// Синтетический контракт, не код чужого репозитория.
|
||
// Billing владеет значением статуса.
|
||
const display = {
|
||
statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'Открыт',
|
||
amountMinor: invoice.amountMinor,
|
||
currencyCode: invoice.currencyCode,
|
||
};
|
||
|
||
// Общая утилита получает только форматируемые primitive values.
|
||
const amountLabel = formatMoney({
|
||
amountMinor: display.amountMinor,
|
||
currencyCode: display.currencyCode,
|
||
locale: 'ru-RU',
|
||
});`),
|
||
h2('Fixed fixture проверяет классификацию, не реальные файлы'),
|
||
p('Fixture для этой статьи хранит три графа как frozen JS records: clean root API, utility-to-domain edge и consumer-to-internal edge. На вход он принимает только marked case id и возвращает `compliant` или `violated` лишь для записанного synthetic случая. Далее decision draft предлагает удалить ровно найденный edge или сохранить public API record. Это полезно для модели: можно проверить, что domain leak не маскируется под deep import и что неизвестный case, попытка передать `repositoryPath` или режим `scan-project` отклоняются.'),
|
||
code(fixtureCommand),
|
||
p('Отчёт fixture прямо возвращает `realRepository=not-read`, `realImportGraph=not-scanned`, `realLint=not-run`, `realCi=not-run` и `productionEffect=not-attempted`. Это не оговорка мелким шрифтом. Она не даёт принять synthetic PASS за доказательство качества текущего монорепозитория. Чтобы проверить настоящий проект, нужны согласованная область, разрешение на чтение, выбранный parser/resolver, зафиксированная toolchain и отдельный результат review.'),
|
||
h2('Симптом → причина → проверка → действие в механике'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Новый consumer импортирует `/internal`, либо shared package импортирует business type, и это кажется быстрым способом избежать adapter-а.',
|
||
'<strong>Причина.</strong> Публичная поверхность не названа; runtime visibility, type resolution и team policy были приняты за один и тот же механизм.',
|
||
'<strong>Проверка.</strong> Сравните каждый edge с root API record: кто владеет входным типом, разрешён ли specifier, покрывает ли выбранный tool именно такой import syntax и есть ли у запрета смысловая альтернатива.',
|
||
'<strong>Действие.</strong> Вынесите domain interpretation к owner-у, сузьте export surface, добавьте named static restriction, а исключения оформляйте отдельным adapter/package review.',
|
||
]),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Контракт не делает все пакеты идеально независимыми. Есть intentional adapters, plugins, generated clients, framework entry points и миграционные периоды. Для них политика должна сказать, кто может пройти границу и как будет удалено исключение. Не используйте `exports` там, где runtime или consumer compatibility этого не поддерживает; не включайте TypeScript mode без проверки emitted output; не выдавайте ESLint diagnostics за анализ dynamic graph. Наконец, boundary rule не заменяет тест поведения public API.'),
|
||
p('Следующий шаг — взять один import, который сегодня выглядит «почти нормальным», и провести его по пяти колонкам из таблицы. Если это domain fact внутри utility, переведите его в primitive data на стороне domain owner. Если consumerу действительно не хватает функции, не разрешайте deep import: опишите новый root export, owner и compatibility. Так механизм останется небольшим и проверяемым, а не превратится в набор инструментов, которыми никто не управляет.'),
|
||
h2('Историческая граница марта 2024'),
|
||
p('Все три источника были доступны к марту 2024: Node v20.11.1, TypeScript 4.7 и ESLint v8.55.0. Статья не приписывает им поздние возможности и не изображает synthetic model проверкой чужого import graph или CI.'),
|
||
]);
|
||
|
||
const field = revision({
|
||
slug: 'editorial-2024-03-field-package-boundaries',
|
||
title: 'Границы пакетов: полевой разбор domain leak в общей утилите',
|
||
categories: ['JavaScript', 'Архитектура'],
|
||
cover: '/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg',
|
||
excerpt: 'Учебный полевой кейс: utility получает InvoiceStatus, consumer пробирается в internal cache. Разбираем стоимость, решение, точку проверки и то, чего synthetic fixture принципиально не умеет доказать.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('Ситуация учебная, но узнаваемая. Есть `platform-formatting`: его позвали форматировать money и date в нескольких feature. В следующей задаче billing просит показать особую подпись для просроченного счёта. Самый короткий код — импортировать `InvoiceStatus` в formatter. Одновременно orders-feature уже берёт `createFormatterCache` по пути `platform-formatting/internal/...`, потому что root export-а не хватило. Оба решения экономят несколько строк сейчас. Цена — новый enum и новый cache key становятся чужими рисками: billing нельзя менять без formatter-а, formatter нельзя чистить без orders-feature, а owner каждого решения не назван.'),
|
||
p('Это не отчёт о настоящем сервисе, репозитории или production-инциденте. Все package names, edges и records ниже — заранее записанный synthetic кейс. Его задача — показать порядок разговора, когда общая утилита незаметно начинает быть платформой. Мы не будем утверждать, что нашли зависимости сканером, что измерили bundle или что запустили CI. В такой ситуации полезнее сначала отделить наблюдаемый симптом от вероятной причины, чем выдать красивую диаграмму за доказательство.'),
|
||
h2('Что именно сломалось в договоре'),
|
||
p('Первый симптом — utility импортирует доменный `InvoiceStatus`. Это не обязательно создаёт runtime cycle и не обязательно ломает сборку. Но formatting-package получает право решать, какое состояние имеет счёт и как оно называется. Второй симптом — consumer использует internal cache. Это делает файловое устройство package частью contract-а, хотя owner не обещал его поддерживать. Симптомы разные: один переносит доменный смысл вверх, другой расширяет surface вниз. Лечить их одним исключением «разрешить импорт» нельзя.'),
|
||
p('Причина общая: public API существовал только в головах. Слово «shared» прочитали как «сюда можно всё общее», а слова `internal` и package root не были policy. Поэтому reviewer видит рабочий import и не может ответить на два коротких вопроса: владеет ли источник этим типом и может ли target менять этот путь без миграции. Пока ответ не записан, каждое следующее удобное использование выглядит равноправным с настоящим API.'),
|
||
table('Карта учебного кейса', ['Наблюдение', 'Что оно означает', 'Кто должен решить', 'Первое безопасное действие'], [
|
||
['formatter импортирует `InvoiceStatus`', 'доменная интерпретация вошла в platform utility', 'owner billing и owner formatting', 'вернуть label/status mapping в billing, оставить formatter primitive inputs'],
|
||
['orders-feature импортирует `/internal/formatter-cache`', 'consumer зависим от внутреннего устройства', 'owner formatting и consumer owner', 'проверить, нужен ли root export или consumer хранит cache сам'],
|
||
['нет списка public names', 'невозможно отличить контракт от файла', 'owner formatting', 'создать one-page record с root specifier и exports'],
|
||
['lint исключение предлагается до решения', 'инструмент маскирует неясную архитектуру', 'reviewer правила', 'сначала согласовать route, потом настроить статический guard'],
|
||
['неизвестна совместимость runtime', 'export map может расходиться с consumer tooling', 'package owner', 'проверить поддерживаемые Node/TypeScript/bundler режимы отдельно'],
|
||
]),
|
||
figure('/assets/editorial/2024/package-boundaries-2024-forbidden-import-route.svg', 'Маршрут учебного разбора: симптом «domain type в utility» ведёт к причине «не назван public API», затем к проверке фиксированного edge и действию «вернуть смысл domain owner-у, оставить root API и зафиксировать запрет». Красная ветка deep import останавливается до internal subpath.', 'Диаграмма — decision route для synthetic кейса. Она не показывает историю коммитов, реальные пакеты, CI jobs или зависимости какого-либо приложения.'),
|
||
h2('Разделить два решения, а не один файл'),
|
||
p('В billing остаётся выбор статуса и текста. Если он нужен UI, billing может отдать `statusLabel` или более строгую display model, но именно owner billing меняет её при появлении нового enum. Formatting получает `amountMinor`, `currencyCode`, `locale` и возвращает строку. Это не «примитивы ради примитивов». Это минимальный набор, который формирует деньги без знания, почему именно эта сумма показана и какое юридическое состояние у документа.'),
|
||
p('Для internal cache есть два возможных исхода. Первый: cache — деталь formatter-а; consumer перестаёт его импортировать и вызывает public `formatMoney`. Второй: cache действительно нужен нескольким consumer-ам и имеет стабильную семантику. Тогда он не становится public случайно. Owner описывает отдельный export, входы, lifetime, invalidation и compatibility. В synthetic кейсе мы не выбираем между этими вариантами за реальную команду. Мы фиксируем, что deep import — сигнал к review, а не доказательство, что любой internal helper надо экспортировать.'),
|
||
h2('Короткий API record для разговора'),
|
||
p('На одной странице достаточно пяти полей. `Owner`: команда или роль, принимающая изменения surface. `Root specifier`: один путь, который можно импортировать. `Public names`: `formatMoney`, `formatIsoDate`. `Forbidden routes`: `internal/*` для consumers и domain packages для utility. `Evidence`: какой tool и какой review подтверждают конкретное правило. Важно добавить срок пересмотра: иначе migration adapter, появившийся на неделю, станет вечной архитектурой.'),
|
||
code(`// Только synthetic illustration. Это не конфигурация реального repo.
|
||
const packageBoundaryRecord = {
|
||
packageName: '@synthetic/platform-formatting',
|
||
publicSpecifier: '@synthetic/platform-formatting',
|
||
publicNames: ['formatMoney', 'formatIsoDate'],
|
||
forbiddenConsumerRoutes: ['@synthetic/platform-formatting/internal/*'],
|
||
forbiddenUtilityTargets: ['@synthetic/billing-domain/*'],
|
||
owner: 'synthetic-formatting-owner',
|
||
reviewBy: 'human-review-required',
|
||
};
|
||
|
||
// InvoiceStatus остаётся у synthetic billing owner.
|
||
// Formatter принимает amountMinor, currencyCode и locale.`),
|
||
h2('Проверка: не путать evidence с догадкой'),
|
||
p('Для учебного кейса fixture содержит три неизменяемых набора edge: `fixed-clean-public-api`, `fixed-utility-imports-domain` и `fixed-consumer-deep-import`. Он принимает только case id, явно отклоняет `repositoryPath`, `scan-project`, чужой scope и неизвестный case. Report и decision draft обязаны иметь точный набор полей и совпасть с канонической fixed записью; лишнее поле, подменённое action или разрежённый список действий не дают вызвать даже учебный rollback. В положительном случае он возвращает root API с двумя именами и отдельно перечисляет запрещённые consumer subpath. В двух отрицательных случаях он возвращает разные codes: `utility-imports-domain` и `consumer-bypasses-public-api`.'),
|
||
code(fixtureCommand),
|
||
h2('Как проходит review решения'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Зафиксируйте exact specifier и imported name из одной заявки или diff. Не расширяйте проблему словами «всё связано со всем».',
|
||
'<strong>Причина.</strong> Спросите: это domain meaning в utility или consumer зависится от package internals? Возможно, одновременно присутствуют обе причины, но они остаются разными карточками работы.',
|
||
'<strong>Проверка.</strong> Сверьте edge с API record, его owner, allowed direction, Node/TypeScript compatibility и ограничением выбранного static rule. Для legacy пути отдельно назовите срок существования adapter-а.',
|
||
'<strong>Действие.</strong> Выберите один из явно названных выходов: вернуть решение domain owner-у; добавить reviewed root export; создать named adapter; отклонить запрос. Зафиксируйте, кто проверит removal исключения.',
|
||
'<strong>Повторная проверка.</strong> После изменения подтвердите public API и реальную toolchain в пределах согласованной области. Не заменяйте эту работу synthetic fixture-ом.',
|
||
]),
|
||
h2('Где команды обычно теряют время'),
|
||
p('Первый тупик — спор о слове «платформа». Не требуется сперва создать отдельную platform team. Достаточно признать, что общий package уже имеет consumers и изменение его surface имеет цену. Второй — запретить всё через glob. Это даёт ложные violations у adapters и подталкивает к suppressions. Третий — объявить любой new export ошибкой. Иногда public API действительно растёт; важно, чтобы рост имел owner, migration и обратимую границу, а не происходил как побочный эффект internal import.'),
|
||
p('Четвёртый тупик — надеяться, что TypeScript type check подтверждает смысл dependency. Compiler правильно проверяет формы модулей и типы, но не знает, кто владеет `InvoiceStatus`. Пятый — считать package `exports` непробиваемой стеной. Node прямо ограничивает такую интерпретацию: encapsulation не является сильной защитой от прямого абсолютного обращения к файлу. И наконец, ESLint имеет область действия static imports; если проект использует dynamic loading или generated layers, это следует признать в policy и проверить отдельным способом.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Кейс не даёт готовую структуру папок, не доказывает производительность, не выбирает versioning scheme и не описывает permission model для всех команд. Node, TypeScript и ESLint решают разные части задачи и зависят от конкретных версий, runtime и build pipeline. Внешний package может иметь ещё более строгие semver-обязательства; внутренний package может жить в migration периоде. Любая реальная проверка boundary должна начинаться с разрешённой области чтения и явного списка инструментов, а не с догадки по именам каталогов.'),
|
||
p('Следующий шаг — провести такой review для одной реальной связи, не для всего монорепозитория. Договоритесь об owner-е, root public API, forbidden route, способе проверять static import и сроке, когда повторите выбор. Если конкретного route пока нет, не добавляйте глобальный запрет ради диаграммы. Если route уже есть, не оставляйте его «временно» без даты. Такая дисциплина не делает систему неподвижной; она делает цену следующей зависимости видимой заранее.'),
|
||
h2('Историческая граница марта 2024'),
|
||
p('К марту 2024 Node.js уже поддерживал package `exports`; TypeScript 4.7 поддерживал Node-oriented `exports`, `imports` и self-reference; ESLint 8.55.0 уже включал расширение `no-restricted-imports`. Эти факты используются только в их заявленных границах. Автор уровня M7 формулирует решение как проверяемый контракт и не изображает synthetic case полевым наблюдением из production.'),
|
||
]);
|
||
|
||
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
|
||
|
||
function verifyFixture() {
|
||
const report = runPackageBoundaryFixture();
|
||
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
|
||
if (failed.length) {
|
||
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
const count = Object.keys(report.assertions).length;
|
||
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) verifyFixture();
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|