F1 — Shared-либы ядра: план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Дизайн и обоснование решений: 2026-08-21-f1-shared-core-libs-design.md.
Карточка единицы: F1 в ROADMAP. Правила кода:
coding-rules.md.
Goal: три shared-либы без зависимостей — money (денежная арифметика), domain-events
(типизированный каталог шины), platform-calendar (расчёт рабочих сроков), — на которых стоят
единицы F2, F3.1, C2, B3.1.
Architecture: новая группа Nx-проектов libs/apis/core/. Каждая либа — чистые функции и типы,
ноль рантайм-зависимостей, в том числе от NestJS: ни модулей, ни провайдеров, ни DI. Публичный API —
только src/index.ts. Деньги считаются в bigint минорных единицах; каталог событий структурно
запрещает денежные поля в payload; календарь параметризуется объектом WorkingCalendar с
дефолтом-константой вместо env.
Tech Stack: TypeScript 5.9 (strict), Nx 22, Jest 30 + ts-jest, Prettier 2.6
(singleQuote, printWidth: 110). NestJS, Prisma и dayjs здесь не используются.
Общие замечания для исполнителя
Заголовок раздела «Общие замечания для исполнителя»- Язык. Код, идентификаторы, тексты исключений — английские. Комментарии — русские, редкие,
про «почему». Тесты:
describe— имя модуля/функции,it— фраза на английском в третьем лице («returns», «rejects»), не «should». - Глубина путей. Все либы лежат на
libs/apis/core/<name>— это четыре уровня от корня, поэтому во всех конфигах относительный путь до корня../../../../. Ошибка здесь ломаетnx testневнятным сообщением про preset. - Проверка после каждой задачи — точные команды указаны в шагах. Заявлять «работает» без просмотра вывода нельзя (coding-rules §11).
- Форматирование перед коммитом:
npx prettier --write <изменённые файлы>. - Ничего лишнего. Не добавлять функции, которых нет в плане: конвертация в валюту, форматирование
сумм, работа с праздниками, публикатор событий — всё это чужие единицы (
B2.1,F2,C1).
Структура файлов
Заголовок раздела «Структура файлов»libs/apis/core/money/ project.json tsconfig.json tsconfig.lib.json tsconfig.spec.json jest.config.ts eslint.config.mjs README.md src/index.ts src/lib/money.constants.ts BPS_DENOMINATOR, MAX_BPS src/lib/money.type.ts SettlementSplitInput, SettlementSplit src/lib/money.helper.ts calculateCommissionFee, splitSettlement + внутренняя валидация src/lib/money.helper.spec.ts
libs/apis/core/domain-events/ <тот же обвяз> src/lib/domain-event.constants.ts DOMAIN_EVENT_ROUTING_KEY src/lib/domain-event.type.ts union'ы, payload'ы, карта, MoneyFree, компиляционные гейты src/lib/domain-event.type.spec.ts
libs/apis/core/platform-calendar/ <тот же обвяз> src/lib/platform-calendar.constants.ts PLATFORM_CALENDAR src/lib/platform-calendar.type.ts WorkingCalendar src/lib/platform-calendar.helper.ts isWorkingTime, addWorkingHours, addWorkingDays, addCalendarDays src/lib/platform-calendar.helper.spec.ts
tsconfig.base.json три алиасаCLAUDE.md строка про libs/apis/core/ в структуре репозиторияdocs/04-shared-and-utils/core-libs.md новая страницаdocs/README.md ссылка на новую страницуdocs/01-overview/architecture.md группа core/ в дереве libs/apis/docs/rules/coding-rules.md §1.2: строка в таблицу схем имёнРазделение файлов внутри либы — по роли, как требует coding-rules §1.1: константы, типы, функции,
тест. Функций мало, поэтому один *.helper.ts на либу; дробить дальше нечего.
Task 1: Каркас либы api-core-money
Заголовок раздела «Task 1: Каркас либы api-core-money»Files:
-
Create:
libs/apis/core/money/project.json -
Create:
libs/apis/core/money/tsconfig.json -
Create:
libs/apis/core/money/tsconfig.lib.json -
Create:
libs/apis/core/money/tsconfig.spec.json -
Create:
libs/apis/core/money/jest.config.ts -
Create:
libs/apis/core/money/eslint.config.mjs -
Create:
libs/apis/core/money/src/index.ts -
Create:
libs/apis/core/money/src/lib/money.constants.ts -
Modify:
tsconfig.base.json(добавить алиас в конец объектаpaths) -
Step 1: Создать конфиги проекта
libs/apis/core/money/project.json:
{ "name": "api-core-money", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/apis/core/money/src", "projectType": "library", "tags": ["scope:shared", "type:util"], "// targets": "to see all targets run: nx show project api-core-money --web", "targets": {}}libs/apis/core/money/tsconfig.json:
{ "extends": "../../../../tsconfig.base.json", "compilerOptions": { "module": "commonjs", "forceConsistentCasingInFileNames": true, "strict": true, "importHelpers": true, "noImplicitOverride": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true }, "files": [], "include": [], "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }]}libs/apis/core/money/tsconfig.lib.json:
{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../../dist/out-tsc", "declaration": true, "types": ["node"], "target": "es2021", "strictNullChecks": true, "noImplicitAny": true, "strictBindCallApply": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true }, "include": ["src/**/*.ts"], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]}target: es2021 обязателен: литералы 10_000n требуют ES2020+.
libs/apis/core/money/tsconfig.spec.json:
{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", "target": "es2021", "types": ["jest", "node"] }, "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]}libs/apis/core/money/jest.config.ts:
export default { displayName: 'api-core-money', preset: '../../../../jest.preset.js', testEnvironment: 'node', transform: { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }], }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../../../coverage/libs/apis/core/money',};libs/apis/core/money/eslint.config.mjs:
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];- Step 2: Создать константы и barrel
libs/apis/core/money/src/lib/money.constants.ts:
/** Знаменатель базисных пунктов: 10 000 bps = 100%. */export const BPS_DENOMINATOR = 10_000n;
/** Максимальная ставка в базисных пунктах. */export const MAX_BPS = 10_000;libs/apis/core/money/src/index.ts:
export * from './lib/money.constants';- Step 3: Зарегистрировать алиас
В tsconfig.base.json, в объекте compilerOptions.paths, после последней записи
(@crewsforge-back/apis/providers/admin-api/data-access) добавить — не забыв запятую после
предыдущей записи:
"@crewsforge-back/apis/core/money": ["libs/apis/core/money/src/index.ts"]- Step 4: Проверить, что Nx видит проект и линт зелёный
npx nx show project api-core-money --json | head -5npx nx lint api-core-moneyОжидается: команда show project печатает JSON с "name": "api-core-money"; линт — Successfully ran target lint. Тесты на этом шаге не запускаются: тестовых файлов ещё нет, Jest упал бы.
- Step 5: Коммит
npx prettier --write libs/apis/core/money tsconfig.base.jsongit add libs/apis/core/money tsconfig.base.jsongit commit -m "feat(core): scaffold the api-core-money library"Task 2: calculateCommissionFee
Заголовок раздела «Task 2: calculateCommissionFee»Files:
-
Create:
libs/apis/core/money/src/lib/money.helper.ts -
Create:
libs/apis/core/money/src/lib/money.helper.spec.ts -
Modify:
libs/apis/core/money/src/index.ts -
Step 1: Написать падающий тест
libs/apis/core/money/src/lib/money.helper.spec.ts:
import { calculateCommissionFee } from './money.helper';
describe('money.helper', () => { describe('calculateCommissionFee', () => { it('rounds the fee up so the platform never receives less than the calculated share', () => { // 100 × 0.01% = 0.01 минорной единицы: round дал бы 0, ceil даёт 1 expect(calculateCommissionFee(100n, 1)).toBe(1n); });
it('returns an exact fee when the rate divides the amount without a remainder', () => { expect(calculateCommissionFee(1_000_000n, 1500)).toBe(150_000n); });
it('returns zero for a zero rate', () => { expect(calculateCommissionFee(500_000n, 0)).toBe(0n); });
it('returns the whole amount for a rate of 100%', () => { expect(calculateCommissionFee(500_000n, 10_000)).toBe(500_000n); });
it('rejects a negative amount', () => { expect(() => calculateCommissionFee(-1n, 1500)).toThrow(RangeError); });
it('rejects a rate above 100%', () => { expect(() => calculateCommissionFee(1_000n, 10_001)).toThrow(RangeError); });
it('rejects a fractional rate', () => { expect(() => calculateCommissionFee(1_000n, 15.5)).toThrow(RangeError); });
it('rejects NaN as a rate', () => { expect(() => calculateCommissionFee(1_000n, Number.NaN)).toThrow(RangeError); }); });});- Step 2: Запустить тест и убедиться, что он падает
npx nx test api-core-moneyОжидается: FAIL — Cannot find module './money.helper'.
- Step 3: Минимальная реализация
libs/apis/core/money/src/lib/money.helper.ts:
import { BPS_DENOMINATOR, MAX_BPS } from './money.constants';
const assertNonNegativeAmount = (amountMinor: bigint, field: string): void => { if (amountMinor < 0n) { throw new RangeError(`${field} must not be negative`); }};
const assertBps = (bps: number, field: string): void => { if (!Number.isInteger(bps) || bps < 0 || bps > MAX_BPS) { throw new RangeError(`${field} must be an integer within [0, ${MAX_BPS}]`); }};
// Округление вверх на bigint: (a × bps + 9999) / 10000. Операнды неотрицательны,// поэтому усечение деления к нулю совпадает с полом, и формула даёт ровно ceil.const multiplyByBpsCeil = (amountMinor: bigint, bps: number): bigint => (amountMinor * BigInt(bps) + BPS_DENOMINATOR - 1n) / BPS_DENOMINATOR;
export const calculateCommissionFee = (amountMinor: bigint, commissionRateBps: number): bigint => { assertNonNegativeAmount(amountMinor, 'amountMinor'); assertBps(commissionRateBps, 'commissionRateBps');
return multiplyByBpsCeil(amountMinor, commissionRateBps);};BigInt(bps) вызывается только после assertBps: смешивать bigint и number в одном выражении
JS запрещает, а дробное значение к этому моменту уже отсеяно.
Дополнить libs/apis/core/money/src/index.ts:
export * from './lib/money.constants';export * from './lib/money.helper';- Step 4: Запустить тест и убедиться, что он зелёный
npx nx test api-core-moneyОжидается: PASS, 8 тестов.
- Step 5: Коммит
npx prettier --write libs/apis/core/moneygit add libs/apis/core/moneygit commit -m "feat(core): add the commission fee calculation"Task 3: splitSettlement
Заголовок раздела «Task 3: splitSettlement»Files:
-
Create:
libs/apis/core/money/src/lib/money.type.ts -
Modify:
libs/apis/core/money/src/lib/money.helper.ts -
Modify:
libs/apis/core/money/src/lib/money.helper.spec.ts -
Modify:
libs/apis/core/money/src/index.ts -
Step 1: Написать падающий тест
Добавить в money.helper.spec.ts внутрь describe('money.helper', ...) — импорт дополнить
splitSettlement:
describe('splitSettlement', () => { it('gives the whole hold to the team minus the fee when no share is passed', () => { expect(splitSettlement({ holdAmountMinor: 1_000_000n, commissionRateBps: 1500 })).toEqual({ teamAmountMinor: 850_000n, platformFeeMinor: 150_000n, founderRefundMinor: 0n, }); });
it('rounds the team share down and refunds the difference to the founder', () => { // 101 × 33.33% = 33.66 → 33 команде брутто, 68 фаундеру; комиссия 15% от 33 = 4.95 → 5 expect(splitSettlement({ holdAmountMinor: 101n, commissionRateBps: 1500, shareBps: 3333 })).toEqual({ teamAmountMinor: 28n, platformFeeMinor: 5n, founderRefundMinor: 68n, }); });
it('takes the commission from the team share, not from the hold', () => { const { platformFeeMinor } = splitSettlement({ holdAmountMinor: 1_000_000n, commissionRateBps: 1500, shareBps: 5000, });
expect(platformFeeMinor).toBe(75_000n); // 15% от 500 000, а не от 1 000 000 });
it('refunds the whole hold to the founder for a zero share', () => { expect(splitSettlement({ holdAmountMinor: 999n, commissionRateBps: 1500, shareBps: 0 })).toEqual({ teamAmountMinor: 0n, platformFeeMinor: 0n, founderRefundMinor: 999n, }); });
it('rejects a negative hold amount', () => { expect(() => splitSettlement({ holdAmountMinor: -1n, commissionRateBps: 1500 })).toThrow(RangeError); });
it('rejects a share above 100%', () => { expect(() => splitSettlement({ holdAmountMinor: 1_000n, commissionRateBps: 1500, shareBps: 10_001 }) ).toThrow(RangeError); });});- Step 2: Запустить тест и убедиться, что он падает
npx nx test api-core-moneyОжидается: FAIL — splitSettlement is not a function (или ошибка компиляции ts-jest про
отсутствующий экспорт).
- Step 3: Минимальная реализация
libs/apis/core/money/src/lib/money.type.ts:
export interface SettlementSplitInput { /** Сумма холда в минорных единицах. */ holdAmountMinor: bigint; /** Снапшот ставки комиссии на проекте, базисные пункты. */ commissionRateBps: number; /** Доля команды в базисных пунктах: решение арбитра или оценка выполненных критериев. */ shareBps?: number;}
export interface SettlementSplit { teamAmountMinor: bigint; platformFeeMinor: bigint; founderRefundMinor: bigint;}Дописать в money.helper.ts (импорт типов — сверху файла):
import { SettlementSplit, SettlementSplitInput } from './money.type';
// Округление вниз: усечение bigint-деления при неотрицательных операндах и есть пол.const multiplyByBpsFloor = (amountMinor: bigint, bps: number): bigint => (amountMinor * BigInt(bps)) / BPS_DENOMINATOR;
export const splitSettlement = ({ holdAmountMinor, commissionRateBps, shareBps = MAX_BPS,}: SettlementSplitInput): SettlementSplit => { assertNonNegativeAmount(holdAmountMinor, 'holdAmountMinor'); assertBps(commissionRateBps, 'commissionRateBps'); assertBps(shareBps, 'shareBps');
const teamGrossMinor = multiplyByBpsFloor(holdAmountMinor, shareBps); const founderRefundMinor = holdAmountMinor - teamGrossMinor; const platformFeeMinor = multiplyByBpsCeil(teamGrossMinor, commissionRateBps); const teamAmountMinor = teamGrossMinor - platformFeeMinor;
// Страховка не от текущей формулы (она тождественна по построению), а от будущей правки: // расчёт обязан остановиться, а не разъехаться с CHECK-констрейнтом на Settlement. if (teamAmountMinor + platformFeeMinor + founderRefundMinor !== holdAmountMinor) { throw new Error('settlement split does not add up to the hold amount'); }
return { teamAmountMinor, platformFeeMinor, founderRefundMinor };};Дополнить src/index.ts:
export * from './lib/money.constants';export * from './lib/money.helper';export * from './lib/money.type';- Step 4: Запустить тест и убедиться, что он зелёный
npx nx test api-core-moneyОжидается: PASS, 14 тестов.
- Step 5: Коммит
npx prettier --write libs/apis/core/moneygit add libs/apis/core/moneygit commit -m "feat(core): add the settlement split calculation"Task 4: Приёмочный прогон инварианта сумм
Заголовок раздела «Task 4: Приёмочный прогон инварианта сумм»Критерий приёмки карточки F1 требует не примера, а свойства: сумма компонент равна холду всегда.
Проверяется перебором, а не отдельными случаями.
Files:
-
Modify:
libs/apis/core/money/src/lib/money.helper.spec.ts -
Step 1: Написать падающий тест
Добавить в money.helper.spec.ts новый блок внутри describe('money.helper', ...):
describe('splitSettlement invariants', () => { const HOLDS = [0n, 1n, 3n, 7n, 99n, 101n, 12_345n, 500_000n, 999_999n, 1_000_001n]; const RATES = [0, 1, 250, 1500, 3333, 9999, 10_000]; const SHARES = [0, 1, 3333, 5000, 6667, 9999, 10_000];
const cases = HOLDS.flatMap((holdAmountMinor) => RATES.flatMap((commissionRateBps) => SHARES.map((shareBps) => ({ holdAmountMinor, commissionRateBps, shareBps })) ) );
it.each(cases)( 'keeps the components adding up to the hold for case %#', ({ holdAmountMinor, commissionRateBps, shareBps }) => { const split = splitSettlement({ holdAmountMinor, commissionRateBps, shareBps });
expect(split.teamAmountMinor + split.platformFeeMinor + split.founderRefundMinor).toBe(holdAmountMinor); } );
it.each(cases)('never produces a negative component for case %#', (input) => { const split = splitSettlement(input);
expect(split.teamAmountMinor).toBeGreaterThanOrEqual(0n); expect(split.platformFeeMinor).toBeGreaterThanOrEqual(0n); expect(split.founderRefundMinor).toBeGreaterThanOrEqual(0n); });});- Step 2: Запустить и убедиться, что тесты зелёные
npx nx test api-core-moneyОжидается: PASS, 994 теста (14 + 2 × 490 комбинаций). Этот тест не должен падать — реализация из Task 3 уже корректна; смысл шага в том, чтобы свойство было закреплено тестом, а не примером. Если хотя бы один случай красный — останови работу и разбирайся: это ошибка в формуле, а не в тесте.
Шаблон имени — %# (порядковый номер случая), а не %j: %j сериализует случай через
JSON.stringify, а тот бросает TypeError: Do not know how to serialize a BigInt. С %j оба
блока схлопываются в два упавших теста, и предыдущий абзац отправил бы чинить исправную формулу.
- Step 3: Коммит
npx prettier --write libs/apis/core/moneygit add libs/apis/core/moneygit commit -m "test(core): sweep the settlement split invariant across amounts and rates"Task 5: Каркас либы api-core-domain-events
Заголовок раздела «Task 5: Каркас либы api-core-domain-events»Files:
-
Create:
libs/apis/core/domain-events/{project.json,tsconfig.json,tsconfig.lib.json,tsconfig.spec.json,jest.config.ts,eslint.config.mjs} -
Create:
libs/apis/core/domain-events/src/index.ts -
Create:
libs/apis/core/domain-events/src/lib/domain-event.constants.ts -
Modify:
tsconfig.base.json -
Step 1: Скопировать обвяз из Task 1 с заменой имени
Все шесть конфигов идентичны файлам libs/apis/core/money/, кроме четырёх мест:
project.json → name и sourceRoot, jest.config.ts → displayName и coverageDirectory. Имя
проекта встречается ещё и в строке-комментарии "// targets" — заменить и там.
Значения: api-core-domain-events, libs/apis/core/domain-events/src,
../../../../coverage/libs/apis/core/domain-events. Теги те же: ["scope:shared", "type:util"].
- Step 2: Создать каталог routing keys
libs/apis/core/domain-events/src/lib/domain-event.constants.ts:
/** * Каталог routing keys шины (шаг 7 §1). Публикует события только исполнитель машин * через outbox — руками ключи нигде не собираются. */export const DOMAIN_EVENT_ROUTING_KEY = { PROJECT_STATUS_CHANGED: 'project.status.changed', MILESTONE_STATUS_CHANGED: 'milestone.status.changed', HOLD_STATUS_CHANGED: 'hold.status.changed', CONTRACT_STATUS_CHANGED: 'contract.status.changed', DISPUTE_STATUS_CHANGED: 'dispute.status.changed', TERMINATION_STATUS_CHANGED: 'termination.status.changed', OFFER_STATUS_CHANGED: 'offer.status.changed', PLAN_VERSION_STATUS_CHANGED: 'plan-version.status.changed', PAYOUT_ACCOUNT_STATUS_CHANGED: 'payout-account.status.changed', TIMER_FIRED: 'timer.fired', WEBHOOK_EXTERNAL_DISPUTE: 'webhook.external-dispute', CONCIERGE_ESCALATED: 'concierge.escalated',} as const;
export type DomainEventRoutingKey = (typeof DOMAIN_EVENT_ROUTING_KEY)[keyof typeof DOMAIN_EVENT_ROUTING_KEY];libs/apis/core/domain-events/src/index.ts:
export * from './lib/domain-event.constants';- Step 3: Зарегистрировать алиас
В tsconfig.base.json рядом с алиасом money:
"@crewsforge-back/apis/core/domain-events": ["libs/apis/core/domain-events/src/index.ts"]- Step 4: Проверить
npx nx show project api-core-domain-events --json | head -5npx nx lint api-core-domain-eventsОжидается: JSON с нужным именем; Successfully ran target lint.
- Step 5: Коммит
npx prettier --write libs/apis/core/domain-events tsconfig.base.jsongit add libs/apis/core/domain-events tsconfig.base.jsongit commit -m "feat(core): scaffold the api-core-domain-events library"Task 6: Типы каталога и структурный запрет денег
Заголовок раздела «Task 6: Типы каталога и структурный запрет денег»Самая тонкая часть единицы. Гейт живёт в исходниках либы, а не в тесте: денежное поле в payload обязано валить компиляцию.
Files:
-
Create:
libs/apis/core/domain-events/src/lib/domain-event.type.ts -
Modify:
libs/apis/core/domain-events/src/index.ts -
Step 1: Написать типы
libs/apis/core/domain-events/src/lib/domain-event.type.ts:
import { DomainEventRoutingKey } from './domain-event.constants';
/** * Значения enum'ов повторяют схему шага 2 (§9, а WebhookProvider — §7) значение в значение. * Prisma-enum'ы появятся в F4 и обязаны совпасть с этими union'ами. */export type ActorType = 'USER' | 'SYSTEM' | 'TIMER';
export type SubjectType = | 'PROJECT' | 'MILESTONE' | 'HOLD' | 'CONTRACT' | 'DISPUTE' | 'TERMINATION' | 'OFFER' | 'TEAM' | 'PAYOUT_ACCOUNT' | 'PLAN_VERSION' | 'CONCIERGE_THREAD';
export type TimerKind = | 'FOUNDER_SILENCE' | 'DISPUTE_POSITION_WINDOW' | 'TERMINATION_OBJECTION_WINDOW' | 'ENVELOPE_EXPIRY' | 'MILESTONE_DEADLINE' | 'SLA_TARGET' | 'REMINDER';
export type WebhookProvider = 'STRIPE' | 'SIGNING' | 'TRACKER';
/** Скаляры, допустимые в payload. bigint отсутствует умышленно: суммы у нас bigint. */export type PayloadScalar = string | number | boolean | null | undefined;
export type ForbiddenMoneyKey = | `${string}Minor` | `${string}Amount` | 'amount' | `${string}Fee` | 'fee' | `${string}Price` | 'price' | 'currency';
/** * Q-3 на шине: события читают все консьюмеры, поэтому денежным полям в payload места нет. * Три рубежа — имя поля, тип значения и запрет вложенных объектов, за которыми проверка * ничего не видит. Индекс-сигнатура отсекается первым условием, union проверяется по членам. */export type MoneyFree<T> = string extends keyof T ? never : T extends object ? Extract<keyof T, ForbiddenMoneyKey> extends never ? T[keyof T] extends PayloadScalar ? T : never : never : never;
export type Expect<T extends true> = T;export type Equals<A, B> = (<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2 ? true : false;export type MoneyFreeMap<T> = { [K in keyof T]: MoneyFree<T[K]> };
export interface StatusChangedPayload { subjectType: SubjectType; subjectId: string; projectId?: string; /** Имя перехода из машины: 'M06', 'H02', ... */ transition: string; /** null — создание сущности; в схеме это StateTransition.fromState String? */ from: string | null; to: string; actorType: ActorType; /** Роль хранится отдельно от типа актора: USER может быть и фаундером, и оператором. */ actorRole?: string; transitionId: string; /** ISO-8601 UTC: payload едет через JSON. */ occurredAt: string;}
export interface TimerFiredPayload { timerId: string; timerKind: TimerKind; subjectType: SubjectType; subjectId: string; projectId?: string; firedAt: string;}
export interface ExternalDisputeWebhookPayload { subjectType: 'HOLD'; subjectId: string; projectId?: string; provider: WebhookProvider; /** Ключ дедупа WebhookEvent. */ externalEventId: string; externalDisputeId: string; occurredAt: string;}
export interface ConciergeEscalatedPayload { subjectType: 'CONCIERGE_THREAD'; subjectId: string; projectId?: string; escalatedByUserId: string; occurredAt: string;}
export interface DomainEventPayloadMap { 'project.status.changed': StatusChangedPayload; 'milestone.status.changed': StatusChangedPayload; 'hold.status.changed': StatusChangedPayload; 'contract.status.changed': StatusChangedPayload; 'dispute.status.changed': StatusChangedPayload; 'termination.status.changed': StatusChangedPayload; 'offer.status.changed': StatusChangedPayload; 'plan-version.status.changed': StatusChangedPayload; 'payout-account.status.changed': StatusChangedPayload; 'timer.fired': TimerFiredPayload; 'webhook.external-dispute': ExternalDisputeWebhookPayload; 'concierge.escalated': ConciergeEscalatedPayload;}
export interface DomainEvent<K extends DomainEventRoutingKey = DomainEventRoutingKey> { routingKey: K; payload: DomainEventPayloadMap[K];}
/** * Компиляционные гейты. Денежное поле делает запись карты отличной от исходной (обычно never, * а для union — уцелевшим не-денежным членом), Equals даёт false, и Expect<false> роняет tsc. * Объявить запись карты как MoneyFree<Payload> нельзя: такое объявление компилируется молча. */export type CatalogIsMoneyFree = Expect<Equals<DomainEventPayloadMap, MoneyFreeMap<DomainEventPayloadMap>>>;
/** Второй гейт: в карте нет забытых и лишних ключей относительно каталога. */export type CatalogCoversEveryRoutingKey = Expect<Equals<keyof DomainEventPayloadMap, DomainEventRoutingKey>>;Дополнить src/index.ts:
export * from './lib/domain-event.constants';export * from './lib/domain-event.type';- Step 2: Убедиться, что гейты пропускают честный каталог
npx nx lint api-core-domain-eventsnpx tsc --noEmit -p libs/apis/core/domain-events/tsconfig.lib.jsonОжидается: обе команды без ошибок. Если tsc ругается на CatalogIsMoneyFree — гейт ложно
срабатывает на честном payload’е, и чинить надо MoneyFree, а не payload.
- Step 3: Проверить, что гейт действительно бьёт
Временно добавить в конец domain-event.type.ts:
export interface BadPayload { subjectId: string; amountMinor: bigint;}export type BadCatalog = { 'hold.status.changed': BadPayload };export type BadGate = Expect<Equals<BadCatalog, MoneyFreeMap<BadCatalog>>>;npx tsc --noEmit -p libs/apis/core/domain-events/tsconfig.lib.jsonОжидается: error TS2344: Type 'false' does not satisfy the constraint 'true' на BadGate.
После проверки удалить эти три объявления — постоянная версия проверки появится в Task 7.
- Step 4: Коммит
npx prettier --write libs/apis/core/domain-eventsgit add libs/apis/core/domain-eventsgit commit -m "feat(core): add the domain event catalog types"Task 7: Тест каталога
Заголовок раздела «Task 7: Тест каталога»Тест здесь двойной: типовые утверждения (падают отказом компиляции) плюс один рантайм-it, без
которого Jest прошёл бы мимо файла и падение компиляции не свалило бы nx test.
Files:
-
Create:
libs/apis/core/domain-events/src/lib/domain-event.type.spec.ts -
Step 1: Написать тест
libs/apis/core/domain-events/src/lib/domain-event.type.spec.ts:
import { DOMAIN_EVENT_ROUTING_KEY, DomainEventRoutingKey } from './domain-event.constants';import { ConciergeEscalatedPayload, DomainEventPayloadMap, Equals, Expect, ExternalDisputeWebhookPayload, MoneyFree, StatusChangedPayload, TimerFiredPayload,} from './domain-event.type';
/** Утверждение «тип отвергнут»: MoneyFree вернул не то, что получил. */type Rejects<T> = Equals<MoneyFree<T>, T> extends true ? false : true;
// Денежное поле по имени.export type _RejectsAmountMinor = Expect<Rejects<{ subjectId: string; amountMinor: bigint }>>;export type _RejectsOptionalAmountMinor = Expect<Rejects<{ subjectId: string; amountMinor?: bigint }>>;export type _RejectsHoldAmount = Expect<Rejects<{ subjectId: string; holdAmount: number }>>;export type _RejectsPlatformFee = Expect<Rejects<{ subjectId: string; platformFee: number }>>;export type _RejectsCurrency = Expect<Rejects<{ subjectId: string; currency: string }>>;
// Денежное значение под нейтральным именем: суммы у нас bigint.export type _RejectsBigIntValue = Expect<Rejects<{ subjectId: string; total: bigint }>>;
// Дыры, которые проверка обязана закрывать.export type _RejectsUnionMember = Expect<Rejects<{ subjectId: string } | { amountMinor: bigint }>>;export type _RejectsNestedObject = Expect<Rejects<{ subjectId: string; meta: { amountMinor: bigint } }>>;export type _RejectsIndexSignature = Expect<Rejects<{ [key: string]: string }>>;
// Контроль: честные payload'ы не отвергаются, иначе гейт бесполезен.export type _AcceptsStatusChanged = Expect<Equals<MoneyFree<StatusChangedPayload>, StatusChangedPayload>>;export type _AcceptsTimerFired = Expect<Equals<MoneyFree<TimerFiredPayload>, TimerFiredPayload>>;export type _AcceptsExternalDispute = Expect< Equals<MoneyFree<ExternalDisputeWebhookPayload>, ExternalDisputeWebhookPayload>>;export type _AcceptsConciergeEscalated = Expect< Equals<MoneyFree<ConciergeEscalatedPayload>, ConciergeEscalatedPayload>>;
describe('domain-event.type', () => { describe('DOMAIN_EVENT_ROUTING_KEY', () => { it('declares twelve distinct routing keys', () => { const keys = Object.values(DOMAIN_EVENT_ROUTING_KEY);
expect(keys).toHaveLength(12); expect(new Set(keys).size).toBe(12); });
it('declares a payload for every routing key', () => { const catalogued: Record<DomainEventRoutingKey, true> = Object.values(DOMAIN_EVENT_ROUTING_KEY).reduce( (acc, key) => ({ ...acc, [key]: true }), {} as Record<DomainEventRoutingKey, true> );
// Ключи карты — те же литералы, что и в каталоге: расхождение поймает тип ниже. const mapKeys: (keyof DomainEventPayloadMap)[] = Object.keys( catalogued ) as (keyof DomainEventPayloadMap)[];
expect(mapKeys).toHaveLength(12); }); });});- Step 2: Запустить и убедиться, что тест зелёный
npx nx test api-core-domain-eventsОжидается: PASS, 2 теста. Если компиляция падает на одном из _Rejects* — значит MoneyFree
пропускает соответствующую форму, и чинить надо тип в domain-event.type.ts.
- Step 3: Убедиться, что тест поймал бы ослабление запрета
Временно заменить в domain-event.type.ts тело MoneyFree на export type MoneyFree<T> = T; и
выполнить:
npx nx test api-core-domain-eventsОжидается: FAIL с TS2344 на каждом _Rejects*. Вернуть исходное тело MoneyFree и убедиться,
что тест снова зелёный.
- Step 4: Коммит
npx prettier --write libs/apis/core/domain-eventsgit add libs/apis/core/domain-eventsgit commit -m "test(core): assert the domain event payloads stay money-free"Task 8: Каркас либы api-core-platform-calendar
Заголовок раздела «Task 8: Каркас либы api-core-platform-calendar»Files:
-
Create:
libs/apis/core/platform-calendar/{project.json,tsconfig.json,tsconfig.lib.json,tsconfig.spec.json,jest.config.ts,eslint.config.mjs} -
Create:
libs/apis/core/platform-calendar/src/index.ts -
Create:
libs/apis/core/platform-calendar/src/lib/platform-calendar.type.ts -
Create:
libs/apis/core/platform-calendar/src/lib/platform-calendar.constants.ts -
Modify:
tsconfig.base.json -
Step 1: Скопировать обвяз из Task 1 с заменой имени
Имя проекта — api-core-platform-calendar, sourceRoot — libs/apis/core/platform-calendar/src,
displayName — то же имя проекта, coverageDirectory — ../../../../coverage/libs/apis/core/platform-calendar,
теги те же. Имя проекта заменить и в строке-комментарии "// targets".
- Step 2: Тип и константа календаря
libs/apis/core/platform-calendar/src/lib/platform-calendar.type.ts:
export interface WorkingCalendar { /** На бете — только UTC: реальная таймзона появится вместе с потребителем. */ timeZone: 'UTC'; /** Рабочие дни недели в нумерации Date#getUTCDay: 0 — воскресенье. */ workDays: readonly number[]; workStartHour: number; workEndHour: number;}libs/apis/core/platform-calendar/src/lib/platform-calendar.constants.ts:
import { WorkingCalendar } from './platform-calendar.type';
/** Рабочий календарь платформы (шаг 7 §3): UTC, 10:00–18:00, пн–пт, без праздников. */export const PLATFORM_CALENDAR: WorkingCalendar = { timeZone: 'UTC', workDays: [1, 2, 3, 4, 5], workStartHour: 10, workEndHour: 18,};libs/apis/core/platform-calendar/src/index.ts:
export * from './lib/platform-calendar.constants';export * from './lib/platform-calendar.type';- Step 3: Зарегистрировать алиас
"@crewsforge-back/apis/core/platform-calendar": ["libs/apis/core/platform-calendar/src/index.ts"]- Step 4: Проверить
npx nx show project api-core-platform-calendar --json | head -5npx nx lint api-core-platform-calendar- Step 5: Коммит
npx prettier --write libs/apis/core/platform-calendar tsconfig.base.jsongit add libs/apis/core/platform-calendar tsconfig.base.jsongit commit -m "feat(core): scaffold the api-core-platform-calendar library"Task 9: isWorkingTime и addCalendarDays
Заголовок раздела «Task 9: isWorkingTime и addCalendarDays»Files:
- Create:
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts - Create:
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts - Modify:
libs/apis/core/platform-calendar/src/index.ts
Даты в тестах задаются ISO-строками с Z — иначе new Date('2026-08-21T17:00') возьмёт локальную
таймзону машины и тест станет плавающим.
- Step 1: Написать падающий тест
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts:
import { addCalendarDays, isWorkingTime } from './platform-calendar.helper';
// 2026-08-21 — пятница; 22-е — суббота, 24-е — понедельник.const FRIDAY_17_00 = new Date('2026-08-21T17:00:00.000Z');const SATURDAY_12_00 = new Date('2026-08-22T12:00:00.000Z');const MONDAY_10_00 = new Date('2026-08-24T10:00:00.000Z');
describe('platform-calendar.helper', () => { describe('isWorkingTime', () => { it('accepts a weekday inside the window', () => { expect(isWorkingTime(FRIDAY_17_00)).toBe(true); });
it('accepts the exact opening moment', () => { expect(isWorkingTime(MONDAY_10_00)).toBe(true); });
it('rejects the exact closing moment', () => { expect(isWorkingTime(new Date('2026-08-24T18:00:00.000Z'))).toBe(false); });
it('rejects a moment before the window opens', () => { expect(isWorkingTime(new Date('2026-08-24T09:59:59.999Z'))).toBe(false); });
it('rejects a weekend', () => { expect(isWorkingTime(SATURDAY_12_00)).toBe(false); });
it('honours a calendar passed explicitly', () => { const sixDayWeek = { timeZone: 'UTC', workDays: [1, 2, 3, 4, 5, 6], workStartHour: 9, workEndHour: 19, } as const;
expect(isWorkingTime(SATURDAY_12_00, sixDayWeek)).toBe(true); }); });
describe('addCalendarDays', () => { it('adds days without looking at the calendar', () => { expect(addCalendarDays(FRIDAY_17_00, 5)).toEqual(new Date('2026-08-26T17:00:00.000Z')); });
it('keeps the time of day', () => { expect(addCalendarDays(SATURDAY_12_00, 1)).toEqual(new Date('2026-08-23T12:00:00.000Z')); }); });});- Step 2: Запустить и убедиться, что падает
npx nx test api-core-platform-calendarОжидается: FAIL — Cannot find module './platform-calendar.helper'.
- Step 3: Минимальная реализация
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts:
import { PLATFORM_CALENDAR } from './platform-calendar.constants';import { WorkingCalendar } from './platform-calendar.type';
const MS_IN_DAY = 86_400_000;
const assertCalendar = (calendar: WorkingCalendar): void => { if (calendar.workDays.length === 0) { throw new RangeError('calendar must declare at least one working day'); }
if (calendar.workStartHour >= calendar.workEndHour) { throw new RangeError('workStartHour must be earlier than workEndHour'); }};
const isWorkingDay = (at: Date, calendar: WorkingCalendar): boolean => calendar.workDays.includes(at.getUTCDay());
const windowBound = (at: Date, hour: number): Date => { const bound = new Date(at); bound.setUTCHours(hour, 0, 0, 0);
return bound;};
export const isWorkingTime = (at: Date, calendar: WorkingCalendar = PLATFORM_CALENDAR): boolean => { assertCalendar(calendar);
// Окно — полуинтервал [start, end): 10:00 внутри, 18:00 уже вне. return ( isWorkingDay(at, calendar) && at >= windowBound(at, calendar.workStartHour) && at < windowBound(at, calendar.workEndHour) );};
export const addCalendarDays = (from: Date, days: number): Date => { if (!Number.isInteger(days)) { throw new RangeError('days must be an integer'); }
return new Date(from.getTime() + days * MS_IN_DAY);};Дополнить src/index.ts строкой export * from './lib/platform-calendar.helper';.
- Step 4: Запустить и убедиться, что зелёный
npx nx test api-core-platform-calendarОжидается: PASS, 8 тестов.
- Step 5: Коммит
npx prettier --write libs/apis/core/platform-calendargit add libs/apis/core/platform-calendargit commit -m "feat(core): add the working time check and calendar day arithmetic"Task 10: addWorkingHours
Заголовок раздела «Task 10: addWorkingHours»Files:
-
Modify:
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts -
Modify:
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts -
Step 1: Написать падающий тест
Импорт дополнить addWorkingHours, добавить блок:
describe('addWorkingHours', () => { it('carries the remainder over the weekend', () => { // Критерий приёмки F1: час до 18:00 в пятницу, три часа с 10:00 понедельника. expect(addWorkingHours(FRIDAY_17_00, 4)).toEqual(new Date('2026-08-24T13:00:00.000Z')); });
it('returns the closing moment when the hours run out exactly at it', () => { expect(addWorkingHours(FRIDAY_17_00, 1)).toEqual(new Date('2026-08-21T18:00:00.000Z')); });
it('starts counting from the next opening when the start is outside the window', () => { expect(addWorkingHours(SATURDAY_12_00, 2)).toEqual(new Date('2026-08-24T12:00:00.000Z')); });
it('stays inside a single window when the hours fit', () => { expect(addWorkingHours(MONDAY_10_00, 3)).toEqual(new Date('2026-08-24T13:00:00.000Z')); });
it('keeps sub-hour precision', () => { expect(addWorkingHours(FRIDAY_17_00, 0.5)).toEqual(new Date('2026-08-21T17:30:00.000Z')); });
it('rejects a negative amount of hours', () => { expect(() => addWorkingHours(FRIDAY_17_00, -1)).toThrow(RangeError); });});- Step 2: Запустить и убедиться, что падает
npx nx test api-core-platform-calendarОжидается: FAIL. Падение происходит на компиляции, а не в рантайме:
TS2305: Module './platform-calendar.helper' has no exported member 'addWorkingHours'.
- Step 3: Минимальная реализация
Дописать в platform-calendar.helper.ts (константу MS_IN_HOUR — рядом с MS_IN_DAY):
const MS_IN_HOUR = 3_600_000;
/** Ближайший момент, с которого можно расходовать рабочее время: сам `at`, если он внутри окна. */const nextWorkingMoment = (at: Date, calendar: WorkingCalendar): Date => { let cursor = new Date(at);
// Календарь провалидирован вызывающим: хотя бы один рабочий день есть, цикл конечен. for (;;) { if (isWorkingDay(cursor, calendar)) { const opensAt = windowBound(cursor, calendar.workStartHour); const closesAt = windowBound(cursor, calendar.workEndHour);
if (cursor < opensAt) { return opensAt; }
if (cursor < closesAt) { return cursor; } }
cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), cursor.getUTCDate() + 1)); }};
export const addWorkingHours = ( from: Date, hours: number, calendar: WorkingCalendar = PLATFORM_CALENDAR): Date => { assertCalendar(calendar);
if (!Number.isFinite(hours) || hours < 0) { throw new RangeError('hours must be a non-negative finite number'); }
let cursor = nextWorkingMoment(from, calendar); let remainingMs = hours * MS_IN_HOUR;
while (remainingMs > 0) { const closesAt = windowBound(cursor, calendar.workEndHour); const availableMs = closesAt.getTime() - cursor.getTime();
// Ровно исчерпали окно — возвращаем момент закрытия, а не открытие следующего. if (remainingMs <= availableMs) { return new Date(cursor.getTime() + remainingMs); }
remainingMs -= availableMs; cursor = nextWorkingMoment(closesAt, calendar); }
return cursor;};- Step 4: Запустить и убедиться, что зелёный
npx nx test api-core-platform-calendarОжидается: PASS, 14 тестов.
- Step 5: Коммит
npx prettier --write libs/apis/core/platform-calendargit add libs/apis/core/platform-calendargit commit -m "feat(core): add working hours arithmetic"Task 11: addWorkingDays
Заголовок раздела «Task 11: addWorkingDays»Files:
-
Modify:
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts -
Modify:
libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts -
Step 1: Написать падающий тест
Импорт дополнить addWorkingDays, добавить блок:
describe('addWorkingDays', () => { it('steps over the weekend instead of counting it', () => { // Критерий приёмки F1: пять рабочих дней с пятницы — следующая пятница. expect(addWorkingDays(FRIDAY_17_00, 5)).toEqual(new Date('2026-08-28T17:00:00.000Z')); });
it('normalises a weekend start to the next working day', () => { // Суббота → понедельник, затем пять рабочих дней → следующий понедельник. expect(addWorkingDays(SATURDAY_12_00, 5)).toEqual(new Date('2026-08-31T12:00:00.000Z')); });
it('counts from a mid-week start', () => { expect(addWorkingDays(new Date('2026-08-19T09:00:00.000Z'), 5)).toEqual( new Date('2026-08-26T09:00:00.000Z') ); });
it('keeps the time of day, including outside the window', () => { expect(addWorkingDays(new Date('2026-08-24T23:15:00.000Z'), 1)).toEqual( new Date('2026-08-25T23:15:00.000Z') ); });
it('normalises a weekend start even for zero days', () => { expect(addWorkingDays(SATURDAY_12_00, 0)).toEqual(new Date('2026-08-24T12:00:00.000Z')); });
it('rejects a fractional amount of days', () => { expect(() => addWorkingDays(FRIDAY_17_00, 1.5)).toThrow(RangeError); });});- Step 2: Запустить и убедиться, что падает
npx nx test api-core-platform-calendarОжидается: FAIL, снова на компиляции:
TS2305: Module './platform-calendar.helper' has no exported member 'addWorkingDays'.
- Step 3: Минимальная реализация
export const addWorkingDays = ( from: Date, days: number, calendar: WorkingCalendar = PLATFORM_CALENDAR): Date => { assertCalendar(calendar);
if (!Number.isInteger(days) || days < 0) { throw new RangeError('days must be a non-negative integer'); }
// Прибавление суток в UTC точное: перевода часов здесь нет, время суток сохраняется. let cursor = new Date(from);
while (!isWorkingDay(cursor, calendar)) { cursor = new Date(cursor.getTime() + MS_IN_DAY); }
let remaining = days;
while (remaining > 0) { cursor = new Date(cursor.getTime() + MS_IN_DAY);
if (isWorkingDay(cursor, calendar)) { remaining -= 1; } }
return cursor;};- Step 4: Запустить и убедиться, что зелёный
npx nx test api-core-platform-calendarОжидается: PASS, 20 тестов.
- Step 5: Коммит
npx prettier --write libs/apis/core/platform-calendargit add libs/apis/core/platform-calendargit commit -m "feat(core): add working days arithmetic"Task 12: README библиотек и документация
Заголовок раздела «Task 12: README библиотек и документация»Документация — часть определения готовности единицы, не опция (documentation-rules).
Files:
-
Create:
libs/apis/core/money/README.md -
Create:
libs/apis/core/domain-events/README.md -
Create:
libs/apis/core/platform-calendar/README.md -
Create:
docs/04-shared-and-utils/core-libs.md -
Modify:
docs/README.md(строка в таблицу раздела 04) -
Modify:
docs/01-overview/architecture.md(группаcore/в деревеlibs/apis/) -
Modify:
docs/rules/coding-rules.md§1.2 (строка в таблицу схем имён) -
Modify:
CLAUDE.md(строкаlibs/apis/core/<name>/в блоке структуры репозитория) -
Step 1: README каждой либы
Скелет (заполняется по-своему для каждой либы):
# <nx-имя проекта>
<Одно предложение: что либа делает и почему существует.>
**Алиас:** `@crewsforge-back/apis/core/<name>` · **Теги:** `scope:shared`, `type:util` ·**Зависимости:** нет
## API
| Экспорт | Назначение || ------- | ---------- || ... | ... |
## Пример
```ts...```
Дизайн и обоснование: [F1](../../../../docs/plans/2026-08-21-f1-shared-core-libs-design.md).Наполнение: назначение, публичный API, пример вызова, ссылка на дизайн.
Для money обязательно указать порядок округления и то, что сумма компонент сплита равна холду
тождественно. Для domain-events — что публикатора здесь нет (он в F2/C1) и что union’ы
обязаны совпадать с Prisma-enum’ами из F4. Для platform-calendar — границы окна [10:00, 18:00)
и что праздников на бете нет.
- Step 2: Страница
docs/04-shared-and-utils/core-libs.md
Описать группу libs/apis/core/: зачем отдельная от shared, три либы, их назначение, теги и
алиасы, а также правило «сюда попадает то, что не зависит ни от NestJS, ни от инфраструктуры».
Добавить строку в таблицу раздела 04 в docs/README.md.
- Step 3: Дерево групп и таблица схем имён
В docs/01-overview/architecture.md найти дерево верхнеуровневых групп libs/apis/
(configs/, providers/, shared/, utils/) и добавить в него core/ — иначе документ
описывает структуру, которой больше нет.
Mermaid-диаграмму зависимостей в том же файле не трогать: у либ ядра пока нет ни одного
потребителя, и подграф core висел бы в ней без единой стрелки. Подграф добавляет F2 — первая
единица, которая на них сошлётся.
В docs/rules/coding-rules.md §1.2, в таблицу схем имён Nx-проектов, добавить строку:
| доменное ядро | api-core-<name> | api-core-money |
Расширение схемы объявлено в дизайне намеренно; без строки в таблице следующая сессия прочтёт
правило буквально и заведёт core-money.
- Step 4: Строка в
CLAUDE.md
В блоке структуры репозитория, перед libs/apis/configs/, добавить:
libs/apis/core/<name>/ доменное ядро без зависимостей: деньги, каталог событий, календарь- Step 5: Проверить ссылки
grep -n "core-libs" docs/README.mdgrep -n "libs/apis/core" CLAUDE.mdgrep -n "api-core" docs/rules/coding-rules.mdgrep -n "── core/" docs/01-overview/architecture.mdОжидается: по одному совпадению на команду. В architecture.md ищется именно ветка дерева
(├── core/), а не полный путь: префикс libs/apis/ стоит там отдельной строкой над ветками,
поэтому подстроки libs/apis/core в файле не появится.
- Step 6: Коммит
npx prettier --write libs/apis/core docs CLAUDE.mdgit add libs/apis/core docs CLAUDE.mdgit commit -m "docs(core): document the core libraries group"Task 13: Финальная проверка единицы
Заголовок раздела «Task 13: Финальная проверка единицы»- Step 1: Прогнать полный набор проверок
npx nx affected -t lint,test,build --base=mainОжидается: Successfully ran targets lint, test, build. Если build пытается собрать приложения,
зависящие от новых либ, — их и должен: новые либы ни от чего не зависят, но проверка сборки
приложений подтверждает, что алиасы прописаны верно.
- Step 2: Проверить, что границы Nx не нарушены
for p in api-core-money api-core-domain-events api-core-platform-calendar; do echo -n "$p: "; npx nx show project "$p" --json | node -e \ 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.stringify(JSON.parse(s).tags)))'donenpx nx graph --file=/tmp/graph.jsonnode -e 'const g=require("/tmp/graph.json").graph;for(const p of ["api-core-money","api-core-domain-events","api-core-platform-calendar"])console.log(p, JSON.stringify(g.dependencies[p]))'Ожидается: у каждого проекта теги ["scope:shared","type:util"] и пустой список зависимостей —
либы ядра не зависят ни от чего. grep по файлу графа здесь не годится: JSON однострочный, и счёт
строк ничего не покажет.
- Step 3: Свериться с критериями приёмки карточки
F1
Каждый критерий подтверждается именем теста и выводом команды, а не утверждением:
| Критерий | Тест |
|---|---|
| Сумма компонент сплита всегда равна холду | splitSettlement invariants → keeps the components adding up to the hold (490 случаев) |
Комиссия по ceil, а не round | calculateCommissionFee → rounds the fee up ... |
| Пятница 17:00 + 4 рабочих часа → понедельник | addWorkingHours → carries the remainder over the weekend |
| 5 рабочих дней не проскакивают выходные | addWorkingDays → steps over the weekend instead of counting it |
| В payload нет денежных полей, запрещено структурно | _Rejects* в domain-event.type.spec.ts + гейт CatalogIsMoneyFree в исходниках |
- Step 4: Закрыть единицу по шагу 11 протокола
Статус F1 в ROADMAP.md → DONE, закрывающая запись в SESSION-LOG.md (обязательно раздел
«Отклонения»: ревизия шага 2 §7/§9 — TimerKind, SubjectType, проза об округлении), push, PR.
Хендофф следующей сессии: F4 обязана сгенерировать Prisma-enum’ы, совпадающие с union’ами
domain-events; B2.1 добавит конвертацию bigint → number для Stripe; C4.1 навесит денежный
линтер на границу api-core-money.