From ee9e90facc11c8d8b8a6e20700df4c4f2aa61781 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 00:18:19 -0400 Subject: [PATCH 01/10] chore: harden public type guardrails --- jest.config.js | 8 ++-- src/types/branded.ts | 58 ++++++---------------------- test/declarations/publicApi.types.ts | 57 +++++++++++++++++++++++++++ test/publicApi/rootExports.test.ts | 4 -- test/types/branded.test.ts | 46 ++++++++++++++++++++++ tsconfig.json | 2 + 6 files changed, 121 insertions(+), 54 deletions(-) create mode 100644 test/types/branded.test.ts diff --git a/jest.config.js b/jest.config.js index b94969d0..9d939831 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,10 +9,10 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], coverageThreshold: { global: { - branches: 60, - functions: 68, - lines: 72, - statements: 72, + branches: 68, + functions: 76, + lines: 75, + statements: 75, }, }, setupFilesAfterEnv: ['/test/setupTests.ts'], diff --git a/src/types/branded.ts b/src/types/branded.ts index 3a5bc49b..c7878139 100644 --- a/src/types/branded.ts +++ b/src/types/branded.ts @@ -143,79 +143,45 @@ export const isSecurityId = createBrandedTypeGuard(); // ===== Conversion Functions ===== /** - * Convert a string to ContractId after validation. + * Convert an unknown value to ContractId after validating that it is a non-empty string. * Throws if the value is not a valid contract ID. */ -export function toContractId(value: string): ContractId { +export function toContractId(value: unknown): ContractId { if (!isContractId(value)) { - throw new Error(`Invalid ContractId: ${value}`); + throw new Error(`Invalid ContractId: ${String(value)}`); } return value; } /** - * Convert a string to PartyId after validation. + * Convert an unknown value to PartyId after validating that it is a non-empty string. * Throws if the value is not a valid party ID. */ -export function toPartyId(value: string): PartyId { +export function toPartyId(value: unknown): PartyId { if (!isPartyId(value)) { - throw new Error(`Invalid PartyId: ${value}`); + throw new Error(`Invalid PartyId: ${String(value)}`); } return value; } /** - * Convert a string to OcfId after validation. + * Convert an unknown value to OcfId after validating that it is a non-empty string. * Throws if the value is not a valid OCF ID. */ -export function toOcfId(value: string): OcfId { +export function toOcfId(value: unknown): OcfId { if (!isOcfId(value)) { - throw new Error(`Invalid OcfId: ${value}`); + throw new Error(`Invalid OcfId: ${String(value)}`); } return value; } /** - * Convert a string to SecurityId after validation. + * Convert an unknown value to SecurityId after validating that it is a non-empty string. * Throws if the value is not a valid security ID. */ -export function toSecurityId(value: string): SecurityId { +export function toSecurityId(value: unknown): SecurityId { if (!isSecurityId(value)) { - throw new Error(`Invalid SecurityId: ${value}`); + throw new Error(`Invalid SecurityId: ${String(value)}`); } return value; } - -// ===== Unsafe Conversion Functions ===== - -/** - * Unsafe conversion from string to ContractId without validation. - * Use only when you are certain the string is a valid contract ID. - */ -export function unsafeToContractId(value: string): ContractId { - return value as ContractId; -} - -/** - * Unsafe conversion from string to PartyId without validation. - * Use only when you are certain the string is a valid party ID. - */ -export function unsafeToPartyId(value: string): PartyId { - return value as PartyId; -} - -/** - * Unsafe conversion from string to OcfId without validation. - * Use only when you are certain the string is a valid OCF ID. - */ -export function unsafeToOcfId(value: string): OcfId { - return value as OcfId; -} - -/** - * Unsafe conversion from string to SecurityId without validation. - * Use only when you are certain the string is a valid security ID. - */ -export function unsafeToSecurityId(value: string): SecurityId { - return value as SecurityId; -} diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 6af28f02..efc11709 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -5,23 +5,35 @@ import { authorizeIssuer, buildCreateIssuerCommand, CapTableBatch, + isContractId, + isOcfId, + isPartyId, + isSecurityId, OcpClient, OcpValidationError, + toContractId, + toOcfId, + toPartyId, + toSecurityId, withdrawAuthorization, type CapTableBatchExecuteResult, type CapTableBatchOperations, + type ContractId, type CreateIssuerParams, type OcfContractId, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, type OcfFinancing, + type OcfId, type OcfIssuer, type OcfObject, type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, type OcfVestingStart, + type PartyId, + type SecurityId, } from '../../dist'; type Assert = T; @@ -35,6 +47,10 @@ type RemovedRootValue = Extract< | 'ENTITY_TAG_MAP' | 'getIssuerAsOcf' | 'getStakeholderAsOcf' + | 'unsafeToContractId' + | 'unsafeToOcfId' + | 'unsafeToPartyId' + | 'unsafeToSecurityId' >; // This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. @@ -64,6 +80,47 @@ void OcpClient; void OcpValidationError; void withdrawAuthorization; +declare const rawIdentifier: string; +const contractIdBrand: ContractId = toContractId(rawIdentifier); +const ocfIdBrand: OcfId = toOcfId(rawIdentifier); +const partyIdBrand: PartyId = toPartyId(rawIdentifier); +const securityIdBrand: SecurityId = toSecurityId(rawIdentifier); + +// @ts-expect-error distinct identifier brands cannot be mixed +const wrongContractIdBrand: ContractId = partyIdBrand; + +declare const unknownIdentifier: unknown; +const parsedContractIdBrand: ContractId = toContractId(unknownIdentifier); +const parsedOcfIdBrand: OcfId = toOcfId(unknownIdentifier); +const parsedPartyIdBrand: PartyId = toPartyId(unknownIdentifier); +const parsedSecurityIdBrand: SecurityId = toSecurityId(unknownIdentifier); +if (isContractId(unknownIdentifier)) { + const narrowed: ContractId = unknownIdentifier; + void narrowed; +} +if (isOcfId(unknownIdentifier)) { + const narrowed: OcfId = unknownIdentifier; + void narrowed; +} +if (isPartyId(unknownIdentifier)) { + const narrowed: PartyId = unknownIdentifier; + void narrowed; +} +if (isSecurityId(unknownIdentifier)) { + const narrowed: SecurityId = unknownIdentifier; + void narrowed; +} + +void contractIdBrand; +void ocfIdBrand; +void partyIdBrand; +void securityIdBrand; +void wrongContractIdBrand; +void parsedContractIdBrand; +void parsedOcfIdBrand; +void parsedPartyIdBrand; +void parsedSecurityIdBrand; + declare const createIssuerParams: CreateIssuerParams; buildCreateIssuerCommand(createIssuerParams); diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts index 8c6b76a9..aaab7158 100644 --- a/test/publicApi/rootExports.test.ts +++ b/test/publicApi/rootExports.test.ts @@ -47,10 +47,6 @@ describe('package root exports', () => { 'toPartyId', 'toResolvedCantonConfig', 'toSecurityId', - 'unsafeToContractId', - 'unsafeToOcfId', - 'unsafeToPartyId', - 'unsafeToSecurityId', 'validateConfig', 'withdrawAuthorization', ]); diff --git a/test/types/branded.test.ts b/test/types/branded.test.ts new file mode 100644 index 00000000..56f01f8e --- /dev/null +++ b/test/types/branded.test.ts @@ -0,0 +1,46 @@ +import { + isContractId, + isOcfId, + isPartyId, + isSecurityId, + toContractId, + toOcfId, + toPartyId, + toSecurityId, + type ContractId, + type OcfId, + type PartyId, + type SecurityId, +} from '../../src'; + +const brandCases = [ + ['ContractId', isContractId, toContractId], + ['OcfId', isOcfId, toOcfId], + ['PartyId', isPartyId, toPartyId], + ['SecurityId', isSecurityId, toSecurityId], +] as const; + +describe('public branded identifier helpers', () => { + it.each(brandCases)('%s accepts and preserves non-empty strings', (_name, guard, convert) => { + expect(guard('identifier-123')).toBe(true); + expect(convert('identifier-123')).toBe('identifier-123'); + }); + + it.each(brandCases)('%s rejects empty and non-string values', (_name, guard, convert) => { + const invalidValues: unknown[] = ['', 0, false, null, undefined, {}, []]; + + for (const value of invalidValues) { + expect(guard(value)).toBe(false); + expect(() => convert(value)).toThrow(`Invalid ${_name}`); + } + }); + + it('keeps identifier brands distinct at compile time', () => { + const contractId: ContractId = toContractId('contract-id'); + const ocfId: OcfId = toOcfId('ocf-id'); + const partyId: PartyId = toPartyId('party-id'); + const securityId: SecurityId = toSecurityId('security-id'); + + expect([contractId, ocfId, partyId, securityId]).toEqual(['contract-id', 'ocf-id', 'party-id', 'security-id']); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7a964bba..3a4c0953 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,9 +9,11 @@ "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noImplicitOverride": true, + "noEmitOnError": true, "noUnusedLocals": true, "noUnusedParameters": true, "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, From 6161ff9736791dc63678a30060a3972d9f728543 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 00:40:46 -0400 Subject: [PATCH 02/10] feat: enforce non-empty OCF arrays --- .../getConvertibleIssuanceAsOcf.ts | 6 +- .../convertibleTransfer/damlToOcf.ts | 4 +- .../getConvertibleTransferAsOcf.ts | 4 +- .../getEquityCompensationIssuanceAsOcf.ts | 12 +- .../getEquityCompensationTransferAsOcf.ts | 7 +- .../damlToStockConsolidation.ts | 4 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 21 +- .../stockTransfer/getStockTransferAsOcf.ts | 4 +- .../vestingTerms/getVestingTermsAsOcf.ts | 7 +- .../getWarrantIssuanceAsOcf.ts | 6 +- .../getWarrantTransferAsOcf.ts | 4 +- src/types/native.ts | 28 +-- src/utils/typeConversions.ts | 26 ++- test/converters/transferConverters.test.ts | 19 +- test/createOcf/falsyFieldRoundtrip.test.ts | 2 +- test/declarations/nonEmptyArrays.types.ts | 90 ++++++++ .../nonEmptyArrayShapes.test.ts | 97 ++++++++ .../schemaAlignment/schemaConformance.test.ts | 105 +++++++++ .../schemaConformanceHarness.ts | 209 ++++++++++++++++++ .../schemaConformanceRegistry.ts | 77 +++++++ test/types/nonEmptyArrays.types.ts | 90 ++++++++ test/utils/typeConversions.test.ts | 24 ++ test/validation/damlToOcfValidation.test.ts | 11 +- 23 files changed, 803 insertions(+), 54 deletions(-) create mode 100644 test/declarations/nonEmptyArrays.types.ts create mode 100644 test/schemaAlignment/nonEmptyArrayShapes.test.ts create mode 100644 test/types/nonEmptyArrays.types.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 7c5de792..b5bf7c05 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -13,6 +13,7 @@ import { isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, + toNonEmptyArray, } from '../../../utils/typeConversions'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; @@ -218,7 +219,10 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti currency: requireString(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), }, convertible_type: convertibleTypeFromDaml(data.convertible_type), - conversion_triggers: conversionTriggers.map(conversionTriggerFromDaml), + conversion_triggers: toNonEmptyArray( + conversionTriggers.map(conversionTriggerFromDaml), + 'convertibleIssuance.conversion_triggers' + ), seniority, security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(boardApprovalDate ? { board_approval_date: damlTimeToDateString(boardApprovalDate) } : {}), diff --git a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts index 80a9a2dd..7696dd19 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts @@ -3,7 +3,7 @@ */ import type { OcfConvertibleTransfer } from '../../../types'; -import { damlMonetaryToNative, damlTimeToDateString } from '../../../utils/typeConversions'; +import { damlMonetaryToNative, damlTimeToDateString, toNonEmptyArray } from '../../../utils/typeConversions'; /** * DAML ConvertibleTransfer data structure. @@ -33,7 +33,7 @@ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData): date: damlTimeToDateString(d.date), security_id: d.security_id, amount: damlMonetaryToNative(d.amount), - resulting_security_ids: d.resulting_security_ids, + resulting_security_ids: toNonEmptyArray(d.resulting_security_ids, 'convertibleTransfer.resulting_security_ids'), ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), ...(d.consideration_text ? { consideration_text: d.consideration_text } : {}), ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), diff --git a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts index 53cee57e..2a23d9f6 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -48,7 +48,7 @@ export async function getConvertibleTransferAsOcf( amount: normalizeNumericString(amountStr), currency: data.amount.currency, }, - resulting_security_ids: data.resulting_security_ids, + resulting_security_ids: toNonEmptyArray(data.resulting_security_ids, 'convertibleTransfer.resulting_security_ids'), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 9e68543d..49938214 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -11,6 +11,7 @@ import type { import { damlMonetaryToNativeWithValidation, damlTimeToDateString, + nonEmptyArrayOrUndefined, normalizeNumericString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -60,9 +61,9 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record | null | undefined); - const vestings = - Array.isArray(d.vestings) && d.vestings.length > 0 - ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { + const vestings = Array.isArray(d.vestings) + ? nonEmptyArrayOrUndefined( + (d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { // Validate vesting amount if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { throw new OcpValidationError('vesting.amount', `Must be string or number, got ${typeof v.amount}`, { @@ -77,8 +78,9 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts b/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts index f4de95cb..ac0ea881 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfEquityCompensationTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -46,7 +46,10 @@ export async function getEquityCompensationTransferAsOcf( date: damlTimeToDateString(data.date, 'equityCompensationTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), - resulting_security_ids: data.resulting_security_ids, + resulting_security_ids: toNonEmptyArray( + data.resulting_security_ids, + 'equityCompensationTransfer.resulting_security_ids' + ), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), diff --git a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index c6efebc9..d01f95a6 100644 --- a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts +++ b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts @@ -3,7 +3,7 @@ */ import type { OcfStockConsolidation } from '../../../types/native'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, toNonEmptyArray } from '../../../utils/typeConversions'; /** DAML StockConsolidationOcfData structure */ export interface DamlStockConsolidationData { @@ -25,7 +25,7 @@ export function damlStockConsolidationToNative(d: DamlStockConsolidationData): O object_type: 'TX_STOCK_CONSOLIDATION', id: d.id, date: damlTimeToDateString(d.date, 'stockConsolidation.date'), - security_ids: d.security_ids, + security_ids: toNonEmptyArray(d.security_ids, 'stockConsolidation.security_ids'), resulting_security_id: d.resulting_security_id, ...(d.reason_text ? { reason_text: d.reason_text } : {}), ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index 4ed7888b..03c67067 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,7 +3,12 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNative, + damlTimeToDateString, + nonEmptyArrayOrUndefined, + normalizeNumericString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { @@ -47,11 +52,13 @@ export function damlStockIssuanceDataToNative( }); } const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) - ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ - date: damlTimeToDateString(vesting.date), - amount: normalizeNumericString(vesting.amount), - })) - : []; + ? nonEmptyArrayOrUndefined( + (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ + date: damlTimeToDateString(vesting.date), + amount: normalizeNumericString(vesting.amount), + })) + ) + : undefined; const issuanceType = damlStockIssuanceTypeToNative(anyD.issuance_type); return { @@ -83,7 +90,7 @@ export function damlStockIssuanceDataToNative( share_price: damlMonetaryToNative(d.share_price), quantity: normalizeNumericString(d.quantity), ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), - ...(vestings.length > 0 ? { vestings } : {}), + ...(vestings ? { vestings } : {}), ...(d.cost_basis && { cost_basis: damlMonetaryToNative(d.cost_basis) }), stock_legend_ids: Array.isArray((d as unknown as { stock_legend_ids?: unknown }).stock_legend_ids) ? (d as unknown as { stock_legend_ids: string[] }).stock_legend_ids diff --git a/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts b/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts index 868501b1..2916e0ea 100644 --- a/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts +++ b/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -45,7 +45,7 @@ export async function getStockTransferAsOcf( date: damlTimeToDateString(data.date, 'stockTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), - resulting_security_ids: data.resulting_security_ids, + resulting_security_ids: toNonEmptyArray(data.resulting_security_ids, 'stockTransfer.resulting_security_ids'), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index cbd88fdb..e17db85e 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -11,7 +11,7 @@ import type { VestingPeriod, VestingTrigger, } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType): AllocationType { @@ -347,7 +347,10 @@ export function damlVestingTermsDataToNative( name: d.name, description: d.description, allocation_type: damlAllocationTypeToNative(d.allocation_type), - vesting_conditions: d.vesting_conditions.map(damlVestingConditionToNative), + vesting_conditions: toNonEmptyArray( + d.vesting_conditions.map(damlVestingConditionToNative), + 'vestingTerms.vesting_conditions' + ), ...(comments.length > 0 ? { comments } : {}), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 5e42ccab..2bb9d9c8 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -3,6 +3,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { GetByContractIdParams } from '../../../types/common'; import type { Monetary, + NonEmptyArray, OcfWarrantIssuance, QuantitySourceType, StockClassConversionRight, @@ -16,6 +17,7 @@ import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, + nonEmptyArrayOrUndefined, normalizeNumericString, } from '../../../utils/typeConversions'; import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; @@ -202,7 +204,7 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined } } -function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { +function vestingsFromDaml(value: unknown): NonEmptyArray | undefined { if (value === null || value === undefined) return undefined; if (!Array.isArray(value)) throw invalid('warrantIssuance.vestings', 'vestings must be an array', value); const vestings = value.map((item, index) => { @@ -216,7 +218,7 @@ function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { amount: normalizeNumericString(amount), }; }); - return vestings.length > 0 ? vestings : undefined; + return nonEmptyArrayOrUndefined(vestings); } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { diff --git a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts index b5ae6e7c..232554a0 100644 --- a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts +++ b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfWarrantTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -45,7 +45,7 @@ export async function getWarrantTransferAsOcf( date: damlTimeToDateString(data.date, 'warrantTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), - resulting_security_ids: data.resulting_security_ids, + resulting_security_ids: toNonEmptyArray(data.resulting_security_ids, 'warrantTransfer.resulting_security_ids'), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), diff --git a/src/types/native.ts b/src/types/native.ts index ecc9da90..6c983039 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -802,10 +802,10 @@ export interface OcfStockIssuance extends OcfObjectBase<'TX_STOCK_ISSUANCE'> { /** Reference to vesting terms object, if used instead of inline vestings */ vesting_terms_id?: string; /** Vesting schedule entries associated directly with this issuance */ - vestings?: VestingSimple[]; + vestings?: NonEmptyArray; /** Cost basis for the shares issued */ cost_basis?: Monetary; - /** Stock legends that apply to this issuance (schema minItems: 1; implementation may allow empty) */ + /** Stock legends that apply to this issuance */ stock_legend_ids: string[]; /** Type of stock issuance (e.g., RSA, Founders) */ issuance_type?: StockIssuanceType; @@ -947,7 +947,7 @@ export interface OcfVestingTerms extends OcfObjectBase<'VESTING_TERMS'> { /** Allocation/rounding type for the vesting schedule */ allocation_type: AllocationType; /** Conditions and triggers that describe the graph of vesting schedules and events */ - vesting_conditions: VestingCondition[]; + vesting_conditions: NonEmptyArray; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } @@ -1035,7 +1035,7 @@ interface OcfEquityCompensationIssuanceFields extends OcfObjectBase<'TX_EQUITY_C /** List of security law exemptions (and applicable jurisdictions) for this security */ security_law_exemptions: SecurityExemption[]; /** Vesting events for the grant */ - vestings?: Vesting[]; + vestings?: NonEmptyArray; /** Expiration date of instrument */ expiration_date: string | null; /** Termination exercise windows after termination events */ @@ -1113,7 +1113,7 @@ export interface OcfConvertibleIssuance extends OcfObjectBase<'TX_CONVERTIBLE_IS /** What kind of convertible instrument is this (of the supported, enumerated types) */ convertible_type: ConvertibleType; /** Convertible - Conversion Trigger Array */ - conversion_triggers: ConvertibleConversionTrigger[]; + conversion_triggers: NonEmptyArray; /** If different convertible instruments have seniority over one another, use this value to build a seniority stack */ seniority: number; /** What pro-rata (if any) is the holder entitled to buy at the next round? (decimal string) */ @@ -1152,7 +1152,7 @@ export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> /** Identifier of the VestingTerms to which this security is subject */ vesting_terms_id?: string; /** Vesting schedule entries associated directly with this issuance */ - vestings?: VestingSimple[]; + vestings?: NonEmptyArray; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } @@ -1285,7 +1285,7 @@ export interface OcfStockTransfer extends OcfObjectBase<'TX_STOCK_TRANSFER'> { /** Quantity of non-monetary security units transferred */ quantity: string; /** Array of identifiers for new securities created as a result of the transfer (min 1 item) */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Identifier for the security that holds the remainder balance (for partial transfers) */ balance_security_id?: string; /** Unstructured text description of consideration provided in exchange for security transfer */ @@ -1333,7 +1333,7 @@ export interface OcfWarrantTransfer extends OcfObjectBase<'TX_WARRANT_TRANSFER'> /** Quantity of warrants being transferred */ quantity: string; /** Array of identifiers for new securities created as a result of the transfer (min 1 item) */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Identifier for the security that holds the remainder balance (for partial transfers) */ balance_security_id?: string; /** Unstructured text description of consideration provided in exchange for security transfer */ @@ -1356,7 +1356,7 @@ export interface OcfConvertibleTransfer extends OcfObjectBase<'TX_CONVERTIBLE_TR /** Amount of convertible being transferred */ amount: Monetary; /** Array of identifiers for new securities created as a result of the transfer (min 1 item) */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Identifier for the security that holds the remainder balance (for partial transfers) */ balance_security_id?: string; /** Unstructured text description of consideration provided in exchange for security transfer */ @@ -1379,7 +1379,7 @@ export interface OcfEquityCompensationTransfer extends OcfObjectBase<'TX_EQUITY_ /** Quantity of equity compensation being transferred */ quantity: string; /** Array of identifiers for new securities created as a result of the transfer (min 1 item) */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Identifier for the security that holds the remainder balance (for partial transfers) */ balance_security_id?: string; /** Unstructured text description of consideration provided in exchange for security transfer */ @@ -1767,7 +1767,7 @@ export interface OcfStockConsolidation extends OcfObjectBase<'TX_STOCK_CONSOLIDA /** Date on which the transaction occurred */ date: string; /** Array of identifiers for securities being consolidated */ - security_ids: string[]; + security_ids: NonEmptyArray; /** Identifier for the new consolidated security (canonical field) */ resulting_security_id: string; /** Reason for the consolidation */ @@ -1832,7 +1832,7 @@ export interface OcfPlanSecurityIssuance extends OcfObjectBase<'TX_PLAN_SECURITY /** Whether security is early exercisable */ early_exercisable?: boolean; /** Inline vesting installments */ - vestings?: Vesting[]; + vestings?: NonEmptyArray; /** Expiration date for this security */ expiration_date: string | null; /** Termination exercise windows */ @@ -1968,7 +1968,7 @@ export interface OcfPlanSecurityTransfer extends OcfObjectBase<'TX_PLAN_SECURITY /** Quantity being transferred */ quantity: string; /** Array of identifiers for new securities resulting from the transfer */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Identifier for the security that holds the remainder balance (for partial transfers) */ balance_security_id?: string; /** Unstructured text description of consideration */ @@ -1992,7 +1992,7 @@ export interface OcfFinancing extends OcfObjectBase<'FINANCING'> { /** Name for the financing */ name: string; /** Array of issuance IDs associated with the financing (minItems: 1) */ - issuance_ids: string[]; + issuance_ids: NonEmptyArray; /** Date on which the financing event occurred (YYYY-MM-DD) */ date: string; /** Unstructured text comments related to and stored for the object */ diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 59a3517c..89e6c2f1 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -7,7 +7,7 @@ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../errors'; -import type { Address, AddressType, ConversionTriggerType, Monetary } from '../types/native'; +import type { Address, AddressType, ConversionTriggerType, Monetary, NonEmptyArray } from '../types/native'; // ===== Type Guards ===== @@ -439,6 +439,26 @@ export function ensureArray(value: T[] | null | undefined): T[] { return []; } +/** Return a non-empty tuple or fail when an external array violates its schema cardinality. */ +export function toNonEmptyArray(values: readonly T[], fieldPath: string): NonEmptyArray { + if (values.length === 0) { + throw new OcpValidationError(fieldPath, 'Array must contain at least one item', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'array with at least one item', + receivedValue: values, + }); + } + const [first, ...rest] = values as readonly [T, ...T[]]; + return [first, ...rest]; +} + +/** Omit an optional array when empty; otherwise preserve its non-empty cardinality in the return type. */ +export function nonEmptyArrayOrUndefined(values: readonly T[]): NonEmptyArray | undefined { + if (values.length === 0) return undefined; + const [first, ...rest] = values as readonly [T, ...T[]]; + return [first, ...rest]; +} + /** * Filter out empty string entries from a comments array. * Defensively handles null values that may appear at runtime despite TypeScript types. @@ -474,7 +494,7 @@ export interface NativeQuantityTransferData { date: string; security_id: string; quantity: string; - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; balance_security_id?: string; consideration_text?: string; comments?: string[]; @@ -493,7 +513,7 @@ export function quantityTransferToNative(d: DamlQuantityTransferData): NativeQua date: damlTimeToDateString(d.date), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), - resulting_security_ids: d.resulting_security_ids, + resulting_security_ids: toNonEmptyArray(d.resulting_security_ids, 'transfer.resulting_security_ids'), ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), ...(d.consideration_text ? { consideration_text: d.consideration_text } : {}), ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), diff --git a/test/converters/transferConverters.test.ts b/test/converters/transferConverters.test.ts index eb964438..c9b8c44e 100644 --- a/test/converters/transferConverters.test.ts +++ b/test/converters/transferConverters.test.ts @@ -73,6 +73,7 @@ describe('Transfer Type Converters', () => { date: '2025-08-05', security_id: 'sec-001', quantity: '1000', + // @ts-expect-error exercise runtime validation for untyped callers resulting_security_ids: [], // Empty array should throw }; @@ -130,13 +131,14 @@ describe('Transfer Type Converters', () => { }); it('throws OcpValidationError when id is missing', () => { - const input = { + // @ts-expect-error exercise runtime validation for untyped callers + const input: OcfConvertibleTransfer = { object_type: 'TX_CONVERTIBLE_TRANSFER', date: '2025-08-10', security_id: 'conv-sec-001', amount: { amount: '50000', currency: 'USD' }, resulting_security_ids: ['conv-result-001'], - } as OcfConvertibleTransfer; + }; expect(() => convertToDaml('convertibleTransfer', input)).toThrow(OcpValidationError); try { @@ -155,6 +157,7 @@ describe('Transfer Type Converters', () => { date: '2025-08-10', security_id: 'conv-sec-001', amount: { amount: '50000', currency: 'USD' }, + // @ts-expect-error exercise runtime validation for untyped callers resulting_security_ids: [], // Empty array should throw }; @@ -212,13 +215,14 @@ describe('Transfer Type Converters', () => { }); it('throws OcpValidationError when id is missing', () => { - const input = { + // @ts-expect-error exercise runtime validation for untyped callers + const input: OcfEquityCompensationTransfer = { object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', date: '2025-08-15', security_id: 'eq-sec-001', quantity: '10000', resulting_security_ids: ['eq-result-001'], - } as OcfEquityCompensationTransfer; + }; expect(() => convertToDaml('equityCompensationTransfer', input)).toThrow(OcpValidationError); try { @@ -237,6 +241,7 @@ describe('Transfer Type Converters', () => { date: '2025-08-15', security_id: 'eq-sec-001', quantity: '10000', + // @ts-expect-error exercise runtime validation for untyped callers resulting_security_ids: [], // Empty array should throw }; @@ -294,13 +299,14 @@ describe('Transfer Type Converters', () => { }); it('throws OcpValidationError when id is missing', () => { - const input = { + // @ts-expect-error exercise runtime validation for untyped callers + const input: OcfWarrantTransfer = { object_type: 'TX_WARRANT_TRANSFER', date: '2025-08-20', security_id: 'warrant-sec-001', quantity: '2500', resulting_security_ids: ['warrant-result-001'], - } as OcfWarrantTransfer; + }; expect(() => convertToDaml('warrantTransfer', input)).toThrow(OcpValidationError); try { @@ -319,6 +325,7 @@ describe('Transfer Type Converters', () => { date: '2025-08-20', security_id: 'warrant-sec-001', quantity: '2500', + // @ts-expect-error exercise runtime validation for untyped callers resulting_security_ids: [], // Empty array should throw }; diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4c522458..1addf077 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -110,7 +110,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { const result = damlVestingTermsDataToNative( daml as unknown as Parameters[0] ); - const portion = result.vesting_conditions[0]?.portion; + const [{ portion }] = result.vesting_conditions; expect(portion).toBeDefined(); expect('remainder' in portion!).toBe(true); expect(portion!.remainder).toBe(false); diff --git a/test/declarations/nonEmptyArrays.types.ts b/test/declarations/nonEmptyArrays.types.ts new file mode 100644 index 00000000..131de0c6 --- /dev/null +++ b/test/declarations/nonEmptyArrays.types.ts @@ -0,0 +1,90 @@ +import type { + OcfConvertibleIssuance, + OcfConvertibleTransfer, + OcfEquityCompensationIssuance, + OcfEquityCompensationTransfer, + OcfFinancing, + OcfPlanSecurityIssuance, + OcfPlanSecurityTransfer, + OcfStockConsolidation, + OcfStockIssuance, + OcfStockPlan, + OcfStockTransfer, + OcfVestingTerms, + OcfWarrantIssuance, + OcfWarrantTransfer, +} from '../../dist'; + +const stockPlanEmptyStockClassIds: Pick = { + // @ts-expect-error stock_class_ids requires at least one item + stock_class_ids: [], +}; +const vestingTermsEmptyConditions: Pick = { + // @ts-expect-error vesting_conditions requires at least one item + vesting_conditions: [], +}; +const financingEmptyIssuanceIds: Pick = { + // @ts-expect-error issuance_ids requires at least one item + issuance_ids: [], +}; +const convertibleIssuanceEmptyTriggers: Pick = { + // @ts-expect-error conversion_triggers requires at least one item + conversion_triggers: [], +}; +const stockIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const equityCompensationIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const warrantIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const stockTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const warrantTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const convertibleTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const equityCompensationTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const stockConsolidationEmptySecurityIds: Pick = { + // @ts-expect-error security_ids requires at least one item + security_ids: [], +}; + +// These deprecated aliases remain root-exported and inherit the same pinned constraints. +const planSecurityIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const planSecurityTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; + +void stockPlanEmptyStockClassIds; +void vestingTermsEmptyConditions; +void financingEmptyIssuanceIds; +void convertibleIssuanceEmptyTriggers; +void stockIssuanceEmptyVestings; +void equityCompensationIssuanceEmptyVestings; +void warrantIssuanceEmptyVestings; +void stockTransferEmptyResults; +void warrantTransferEmptyResults; +void convertibleTransferEmptyResults; +void equityCompensationTransferEmptyResults; +void stockConsolidationEmptySecurityIds; +void planSecurityIssuanceEmptyVestings; +void planSecurityTransferEmptyResults; diff --git a/test/schemaAlignment/nonEmptyArrayShapes.test.ts b/test/schemaAlignment/nonEmptyArrayShapes.test.ts new file mode 100644 index 00000000..c54cfa4b --- /dev/null +++ b/test/schemaAlignment/nonEmptyArrayShapes.test.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; +import path from 'path'; +import { OcpValidationError } from '../../src/errors'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function loadFixture(relativePath: string, nestedDb = true): Record { + const parsed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'fixtures', relativePath), 'utf8')) as unknown; + if (!isRecord(parsed)) throw new Error(`Fixture is not an object: ${relativePath}`); + const fixture = nestedDb ? parsed.db : parsed; + if (!isRecord(fixture)) throw new Error(`Fixture has no object payload: ${relativePath}`); + return Object.fromEntries(Object.entries(fixture).filter(([property]) => property !== '_source')); +} + +const nonEmptyArrayCases: Array<{ + input: Record; + label: string; + property: string; +}> = [ + { + input: loadFixture('createOcf/STOCK_PLAN-8cac3fcd-7fdf-41d2-9583-4bdf912629e0.json'), + label: 'StockPlan.stock_class_ids', + property: 'stock_class_ids', + }, + { + input: loadFixture('createOcf/VESTING_TERMS-54ea76f8-38c1-4552-bb8b-ee19625ab1d4.json'), + label: 'VestingTerms.vesting_conditions', + property: 'vesting_conditions', + }, + { + input: { + object_type: 'FINANCING', + id: 'financing-1', + name: 'Series A', + issuance_ids: ['issuance-1'], + date: '2026-01-01', + }, + label: 'Financing.issuance_ids', + property: 'issuance_ids', + }, + { + input: loadFixture('createOcf/TX_CONVERTIBLE_ISSUANCE-1d5b1aa2-2f4d-4022-83ec-c351e0d75189.json'), + label: 'ConvertibleIssuance.conversion_triggers', + property: 'conversion_triggers', + }, + { + input: loadFixture('createOcf/TX_STOCK_ISSUANCE-b4f7e9b5-81df-4559-b4d0-74d0c97ad1e0.json'), + label: 'StockIssuance.vestings', + property: 'vestings', + }, + { + input: loadFixture('createOcf/TX_EQUITY_COMPENSATION_ISSUANCE-4f728ee6-6fd0-4a8c-917b-6ba47877ce71.json'), + label: 'EquityCompensationIssuance.vestings', + property: 'vestings', + }, + { + input: loadFixture('createOcf/TX_WARRANT_ISSUANCE-22b896cb-92df-4fd8-9e52-d0d4112b3f98.json'), + label: 'WarrantIssuance.vestings', + property: 'vestings', + }, + { + input: loadFixture('createOcf/TX_STOCK_TRANSFER-e0d8df57-96b6-4368-9ab9-fdd9d6dbe1db.json'), + label: 'StockTransfer.resulting_security_ids', + property: 'resulting_security_ids', + }, + { + input: loadFixture('synthetic/warrantTransfer.json', false), + label: 'WarrantTransfer.resulting_security_ids', + property: 'resulting_security_ids', + }, + { + input: loadFixture('createOcf/TX_CONVERTIBLE_TRANSFER-c1d2e3f4-5a6b-7c8d-9e0f-a1b2c3d4e5f6.json'), + label: 'ConvertibleTransfer.resulting_security_ids', + property: 'resulting_security_ids', + }, + { + input: loadFixture('synthetic/equityCompensationTransfer.json', false), + label: 'EquityCompensationTransfer.resulting_security_ids', + property: 'resulting_security_ids', + }, + { + input: loadFixture('synthetic/stockConsolidation.json', false), + label: 'StockConsolidation.security_ids', + property: 'security_ids', + }, +]; + +describe('pinned non-empty top-level OCF arrays', () => { + it.each(nonEmptyArrayCases)('rejects an empty $label array', ({ input, property }) => { + expect(parseOcfObject(input)).toBeDefined(); + expect(() => parseOcfObject({ ...input, [property]: [] })).toThrow(OcpValidationError); + expect(() => parseOcfObject({ ...input, [property]: [] })).toThrow('must NOT have fewer than 1 items'); + }); +}); diff --git a/test/schemaAlignment/schemaConformance.test.ts b/test/schemaAlignment/schemaConformance.test.ts index 6a3cbd3e..27b194bd 100644 --- a/test/schemaAlignment/schemaConformance.test.ts +++ b/test/schemaAlignment/schemaConformance.test.ts @@ -2,13 +2,16 @@ import Ajv from 'ajv'; import fs from 'fs'; import path from 'path'; import { + compareCanonicalNonEmptyArrays, compareCanonicalOcfPropertySets, compareConditionalRegistry, dereferencePinnedObjectSchemas, dereferencePinnedSchemaFile, discoverConditionalPathsInValue, getNamedTypeProperty, + inventoryCanonicalOcfNonEmptyArrays, inventoryCanonicalOcfObjects, + inventoryPinnedOcfNonEmptyArrays, inventoryPinnedOcfObjectProperties, inventoryReachableObjectSchemas, validateCoverageReferences, @@ -19,6 +22,7 @@ import { CANONICAL_PROPERTY_PARITY_EXCLUSIONS, EXPECTED_SEMANTIC_REFINEMENTS, OCF_CONDITIONAL_COVERAGE, + PINNED_CANONICAL_NON_EMPTY_ARRAYS, PINNED_REACHABLE_SCHEMA_FINGERPRINT, } from './schemaConformanceRegistry'; @@ -159,6 +163,107 @@ describe('schema-driven OCF conformance guardrail', () => { }, ]); }); + + it('marks a property-parity exclusion stale once the underlying property sets match', () => { + expect( + compareCanonicalOcfPropertySets( + [{ discriminator: 'SYNTHETIC', optionalProperties: [], requiredProperties: ['id', 'object_type'] }], + [ + { + discriminator: 'SYNTHETIC', + properties: ['id', 'object_type'], + schemaPath: 'schema/objects/Synthetic.schema.json', + }, + ], + [ + { + discriminator: 'SYNTHETIC', + kind: 'schema-only', + property: 'retired_schema_field', + rationale: 'Synthetic exclusion that should become stale.', + }, + ] + ) + ).toEqual([ + { + discriminator: 'SYNTHETIC', + kind: 'stale-exclusion', + property: 'retired_schema_field', + }, + ]); + }); + + it('matches every pinned canonical minItems constraint to an SDK NonEmptyArray property', () => { + const canonicalInventory = inventoryCanonicalOcfObjects(REPO_ROOT); + const canonicalDiscriminators = new Set(canonicalInventory.map((entry) => entry.discriminator)); + const pinnedInventory = inventoryPinnedOcfNonEmptyArrays(SCHEMA_ROOT).filter((entry) => + canonicalDiscriminators.has(entry.discriminator) + ); + const sdkInventory = inventoryCanonicalOcfNonEmptyArrays(REPO_ROOT); + + expect(pinnedInventory).toEqual(PINNED_CANONICAL_NON_EMPTY_ARRAYS); + expect(compareCanonicalNonEmptyArrays(canonicalInventory, sdkInventory, pinnedInventory)).toEqual([]); + }); + + it('detects missing, extra, and unsupported NonEmptyArray mappings', () => { + const canonical = [ + { discriminator: 'SYNTHETIC', optionalProperties: ['optional_items'], requiredProperties: ['items'] }, + ]; + expect( + compareCanonicalNonEmptyArrays( + canonical, + [ + { discriminator: 'SYNTHETIC', property: 'extra_items' }, + { discriminator: 'SYNTHETIC', property: 'optional_items' }, + ], + [ + { + discriminator: 'SYNTHETIC', + minItems: 1, + property: 'items', + schemaPath: 'schema/objects/Synthetic.schema.json', + }, + { + discriminator: 'SYNTHETIC', + minItems: 2, + property: 'optional_items', + schemaPath: 'schema/objects/Synthetic.schema.json', + }, + ] + ) + ).toEqual([ + { + discriminator: 'SYNTHETIC', + kind: 'schema-only', + minItems: 1, + property: 'items', + schemaPath: 'schema/objects/Synthetic.schema.json', + }, + { + discriminator: 'SYNTHETIC', + kind: 'sdk-only', + property: 'extra_items', + }, + { + discriminator: 'SYNTHETIC', + kind: 'unsupported-min-items', + minItems: 2, + property: 'optional_items', + schemaPath: 'schema/objects/Synthetic.schema.json', + }, + ]); + }); + + it('keeps deprecated root-exported PlanSecurity aliases aligned with inherited minItems constraints', () => { + expect(getNamedTypeProperty(REPO_ROOT, 'OcfPlanSecurityIssuance', 'vestings')).toEqual({ + optional: true, + type: 'NonEmptyArray', + }); + expect(getNamedTypeProperty(REPO_ROOT, 'OcfPlanSecurityTransfer', 'resulting_security_ids')).toEqual({ + optional: false, + type: 'NonEmptyArray', + }); + }); }); describe('intentional SDK semantic refinements', () => { diff --git a/test/schemaAlignment/schemaConformanceHarness.ts b/test/schemaAlignment/schemaConformanceHarness.ts index 95e888bc..9c41b368 100644 --- a/test/schemaAlignment/schemaConformanceHarness.ts +++ b/test/schemaAlignment/schemaConformanceHarness.ts @@ -37,6 +37,24 @@ export interface PinnedOcfObjectPropertyInventoryEntry { schemaPath: string; } +export interface NonEmptyArrayPropertyInventoryEntry { + discriminator: string; + property: string; +} + +export interface PinnedOcfNonEmptyArrayInventoryEntry extends NonEmptyArrayPropertyInventoryEntry { + minItems: number; + schemaPath: string; +} + +export interface NonEmptyArrayParityProblem { + discriminator: string; + kind: 'duplicate-schema' | 'schema-only' | 'sdk-only' | 'unsupported-min-items'; + minItems?: number; + property: string; + schemaPath?: string; +} + export interface CanonicalPropertyParityExclusion { discriminator: string; kind: 'schema-only' | 'sdk-only'; @@ -353,6 +371,47 @@ function collectComposedObjectProperties(schema: unknown, properties: Set= 0 + ? schema.minItems + : 0; + + const { allOf } = schema; + if (Array.isArray(allOf)) { + minimum = Math.max(minimum, ...allOf.map((branch) => guaranteedMinItems(branch))); + } + + for (const keyword of ['anyOf', 'oneOf'] as const) { + const branches = schema[keyword]; + if (Array.isArray(branches) && branches.length > 0) { + minimum = Math.max(minimum, Math.min(...branches.map((branch) => guaranteedMinItems(branch)))); + } + } + + return minimum; +} + +function collectUnconditionalTopLevelNonEmptyArrays(schema: unknown, properties: Map): void { + if (!isJsonObject(schema)) return; + + if (isJsonObject(schema.properties)) { + for (const [property, propertySchema] of Object.entries(schema.properties)) { + const minItems = guaranteedMinItems(propertySchema); + if (minItems >= 1) { + properties.set(property, Math.max(properties.get(property) ?? 0, minItems)); + } + } + } + + const { allOf } = schema; + if (Array.isArray(allOf)) { + allOf.forEach((branch) => collectUnconditionalTopLevelNonEmptyArrays(branch, properties)); + } +} + function getObjectSchemaDiscriminators(schema: JsonObject, source: string): string[] { const { properties } = schema; if (!isJsonObject(properties)) throw new Error(`Object schema has no properties object: ${source}`); @@ -396,6 +455,99 @@ export function inventoryPinnedOcfObjectProperties(schemaRoot: string): PinnedOc .sort((left, right) => left.discriminator.localeCompare(right.discriminator)); } +/** Inventory unconditional top-level array properties whose pinned schema requires at least one item. */ +export function inventoryPinnedOcfNonEmptyArrays(schemaRoot: string): PinnedOcfNonEmptyArrayInventoryEntry[] { + const objectRoot = path.join(schemaRoot, 'objects'); + return listSchemaFiles(objectRoot) + .flatMap((schemaPath) => { + const dereferenced = dereferenceValue( + readSchemaFile(schemaPath), + schemaPath, + schemaRoot, + new Set([`${schemaPath}#`]) + ); + assertJsonObject(dereferenced, `dereferenced ${schemaPath}`); + const properties = new Map(); + collectUnconditionalTopLevelNonEmptyArrays(dereferenced, properties); + const relativeSchemaPath = `schema/objects/${normalizeSlashes(path.relative(objectRoot, schemaPath))}`; + return getObjectSchemaDiscriminators(dereferenced, schemaPath).flatMap((discriminator) => + [...properties] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([property, minItems]) => ({ discriminator, minItems, property, schemaPath: relativeSchemaPath })) + ); + }) + .sort( + (left, right) => + left.discriminator.localeCompare(right.discriminator) || left.property.localeCompare(right.property) + ); +} + +function nonEmptyArrayPropertyKey(discriminator: string, property: string): string { + return `${discriminator}:${property}`; +} + +/** Compare pinned non-empty arrays with the canonical public properties that use NonEmptyArray. */ +export function compareCanonicalNonEmptyArrays( + canonicalInventory: readonly CanonicalOcfObjectInventoryEntry[], + sdkInventory: readonly NonEmptyArrayPropertyInventoryEntry[], + schemaInventory: readonly PinnedOcfNonEmptyArrayInventoryEntry[] +): NonEmptyArrayParityProblem[] { + const canonicalDiscriminators = new Set(canonicalInventory.map((entry) => entry.discriminator)); + const schemaByKey = new Map(); + const problems: NonEmptyArrayParityProblem[] = []; + + for (const entry of schemaInventory) { + if (!canonicalDiscriminators.has(entry.discriminator)) continue; + const key = nonEmptyArrayPropertyKey(entry.discriminator, entry.property); + if (schemaByKey.has(key)) { + problems.push({ + discriminator: entry.discriminator, + kind: 'duplicate-schema', + property: entry.property, + schemaPath: entry.schemaPath, + }); + continue; + } + schemaByKey.set(key, entry); + if (entry.minItems !== 1) { + problems.push({ + discriminator: entry.discriminator, + kind: 'unsupported-min-items', + minItems: entry.minItems, + property: entry.property, + schemaPath: entry.schemaPath, + }); + } + } + + const sdkByKey = new Map( + sdkInventory.map((entry) => [nonEmptyArrayPropertyKey(entry.discriminator, entry.property), entry] as const) + ); + for (const [key, schema] of schemaByKey) { + if (!sdkByKey.has(key)) { + problems.push({ + discriminator: schema.discriminator, + kind: 'schema-only', + minItems: schema.minItems, + property: schema.property, + schemaPath: schema.schemaPath, + }); + } + } + for (const [key, sdk] of sdkByKey) { + if (!schemaByKey.has(key)) { + problems.push({ discriminator: sdk.discriminator, kind: 'sdk-only', property: sdk.property }); + } + } + + return problems.sort( + (left, right) => + left.discriminator.localeCompare(right.discriminator) || + left.kind.localeCompare(right.kind) || + left.property.localeCompare(right.property) + ); +} + function propertyDifferenceKey( kind: CanonicalPropertyParityExclusion['kind'], discriminator: string, @@ -723,6 +875,63 @@ export function inventoryCanonicalOcfObjects(repoRoot: string): CanonicalOcfObje return inventory.sort((left, right) => left.discriminator.localeCompare(right.discriminator)); } +function isNonEmptyArrayAlias(type: ts.Type): boolean { + const members = type.isUnion() + ? type.types.filter((member) => (member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0) + : [type]; + return members.length > 0 && members.every((member) => member.aliasSymbol?.getName() === 'NonEmptyArray'); +} + +/** Inventory canonical public properties that are explicitly modeled with NonEmptyArray. */ +export function inventoryCanonicalOcfNonEmptyArrays(repoRoot: string): NonEmptyArrayPropertyInventoryEntry[] { + const { checker, program } = loadTypeScriptContext(repoRoot); + const outputPath = path.join(repoRoot, 'src', 'types', 'output.ts'); + const sourceFile = program.getSourceFile(outputPath); + if (!sourceFile) throw new Error(`TypeScript program did not load ${outputPath}`); + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) throw new Error('Could not resolve the output.ts module symbol'); + const ocfObjectSymbol = checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === 'OcfObject'); + if (!ocfObjectSymbol) throw new Error('Could not resolve exported OcfObject type'); + const ocfObjectType = checker.getDeclaredTypeOfSymbol(ocfObjectSymbol); + const variants = ocfObjectType.isUnion() ? ocfObjectType.types : [ocfObjectType]; + + const variantsByDiscriminator = new Map(); + for (const variant of variants) { + const objectType = checker.getPropertyOfType(variant, 'object_type'); + if (!objectType) throw new Error(`OcfObject variant has no object_type: ${checker.typeToString(variant)}`); + const discriminators = literalStrings(checker.getTypeOfSymbolAtLocation(objectType, sourceFile)); + if (discriminators.length !== 1 || discriminators[0] === undefined) { + throw new Error(`OcfObject variant must have one literal object_type: ${checker.typeToString(variant)}`); + } + const discriminator = discriminators[0]; + const existing = variantsByDiscriminator.get(discriminator) ?? []; + existing.push(variant); + variantsByDiscriminator.set(discriminator, existing); + } + + const inventory: NonEmptyArrayPropertyInventoryEntry[] = []; + for (const [discriminator, discriminatorVariants] of variantsByDiscriminator) { + const propertyNames = new Set(); + discriminatorVariants.forEach((variant) => + checker.getPropertiesOfType(variant).forEach((property) => propertyNames.add(property.name)) + ); + for (const property of [...propertyNames].sort((left, right) => left.localeCompare(right))) { + const nonEmptyInEveryVariant = discriminatorVariants.every((variant) => { + const propertySymbol = checker.getPropertyOfType(variant, property); + if (!propertySymbol) return false; + const location = propertySymbol.valueDeclaration ?? propertySymbol.declarations?.[0] ?? sourceFile; + return isNonEmptyArrayAlias(checker.getTypeOfSymbolAtLocation(propertySymbol, location)); + }); + if (nonEmptyInEveryVariant) inventory.push({ discriminator, property }); + } + } + + return inventory.sort( + (left, right) => + left.discriminator.localeCompare(right.discriminator) || left.property.localeCompare(right.property) + ); +} + export function getNamedTypeProperty( repoRoot: string, exportedTypeName: string, diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index 8845d2cd..a59c8667 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -2,6 +2,7 @@ import type { CanonicalPropertyParityExclusion, ConditionalCoverageRegistration, CoverageReference, + PinnedOcfNonEmptyArrayInventoryEntry, SemanticRefinement, } from './schemaConformanceHarness'; @@ -30,6 +31,82 @@ export const CANONICAL_PROPERTY_PARITY_EXCLUSIONS: CanonicalPropertyParityExclus }, ]; +/** Exact pinned canonical top-level array properties whose schema requires at least one item. */ +export const PINNED_CANONICAL_NON_EMPTY_ARRAYS: PinnedOcfNonEmptyArrayInventoryEntry[] = [ + { + discriminator: 'FINANCING', + minItems: 1, + property: 'issuance_ids', + schemaPath: 'schema/objects/Financing.schema.json', + }, + { + discriminator: 'STOCK_PLAN', + minItems: 1, + property: 'stock_class_ids', + schemaPath: 'schema/objects/StockPlan.schema.json', + }, + { + discriminator: 'TX_CONVERTIBLE_ISSUANCE', + minItems: 1, + property: 'conversion_triggers', + schemaPath: 'schema/objects/transactions/issuance/ConvertibleIssuance.schema.json', + }, + { + discriminator: 'TX_CONVERTIBLE_TRANSFER', + minItems: 1, + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/ConvertibleTransfer.schema.json', + }, + { + discriminator: 'TX_EQUITY_COMPENSATION_ISSUANCE', + minItems: 1, + property: 'vestings', + schemaPath: 'schema/objects/transactions/issuance/EquityCompensationIssuance.schema.json', + }, + { + discriminator: 'TX_EQUITY_COMPENSATION_TRANSFER', + minItems: 1, + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/EquityCompensationTransfer.schema.json', + }, + { + discriminator: 'TX_STOCK_CONSOLIDATION', + minItems: 1, + property: 'security_ids', + schemaPath: 'schema/objects/transactions/consolidation/StockConsolidation.schema.json', + }, + { + discriminator: 'TX_STOCK_ISSUANCE', + minItems: 1, + property: 'vestings', + schemaPath: 'schema/objects/transactions/issuance/StockIssuance.schema.json', + }, + { + discriminator: 'TX_STOCK_TRANSFER', + minItems: 1, + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/StockTransfer.schema.json', + }, + { + discriminator: 'TX_WARRANT_ISSUANCE', + minItems: 1, + property: 'vestings', + schemaPath: 'schema/objects/transactions/issuance/WarrantIssuance.schema.json', + }, + { + discriminator: 'TX_WARRANT_TRANSFER', + minItems: 1, + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/WarrantTransfer.schema.json', + }, + { + discriminator: 'VESTING_TERMS', + minItems: 1, + property: 'vesting_conditions', + schemaPath: 'schema/objects/VestingTerms.schema.json', + }, +]; + const coreRuntime: CoverageReference = { file: 'test/schemaAlignment/coreConditionalShapes.test.ts', kind: 'runtime', diff --git a/test/types/nonEmptyArrays.types.ts b/test/types/nonEmptyArrays.types.ts new file mode 100644 index 00000000..982e53f2 --- /dev/null +++ b/test/types/nonEmptyArrays.types.ts @@ -0,0 +1,90 @@ +import type { + OcfConvertibleIssuance, + OcfConvertibleTransfer, + OcfEquityCompensationIssuance, + OcfEquityCompensationTransfer, + OcfFinancing, + OcfPlanSecurityIssuance, + OcfPlanSecurityTransfer, + OcfStockConsolidation, + OcfStockIssuance, + OcfStockPlan, + OcfStockTransfer, + OcfVestingTerms, + OcfWarrantIssuance, + OcfWarrantTransfer, +} from '../../src/types/native'; + +const stockPlanEmptyStockClassIds: Pick = { + // @ts-expect-error stock_class_ids requires at least one item + stock_class_ids: [], +}; +const vestingTermsEmptyConditions: Pick = { + // @ts-expect-error vesting_conditions requires at least one item + vesting_conditions: [], +}; +const financingEmptyIssuanceIds: Pick = { + // @ts-expect-error issuance_ids requires at least one item + issuance_ids: [], +}; +const convertibleIssuanceEmptyTriggers: Pick = { + // @ts-expect-error conversion_triggers requires at least one item + conversion_triggers: [], +}; +const stockIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const equityCompensationIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const warrantIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const stockTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const warrantTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const convertibleTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const equityCompensationTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; +const stockConsolidationEmptySecurityIds: Pick = { + // @ts-expect-error security_ids requires at least one item + security_ids: [], +}; + +// These deprecated aliases remain root-exported and inherit the same pinned constraints. +const planSecurityIssuanceEmptyVestings: Pick = { + // @ts-expect-error when present, vestings requires at least one item + vestings: [], +}; +const planSecurityTransferEmptyResults: Pick = { + // @ts-expect-error resulting_security_ids requires at least one item + resulting_security_ids: [], +}; + +void stockPlanEmptyStockClassIds; +void vestingTermsEmptyConditions; +void financingEmptyIssuanceIds; +void convertibleIssuanceEmptyTriggers; +void stockIssuanceEmptyVestings; +void equityCompensationIssuanceEmptyVestings; +void warrantIssuanceEmptyVestings; +void stockTransferEmptyResults; +void warrantTransferEmptyResults; +void convertibleTransferEmptyResults; +void equityCompensationTransferEmptyResults; +void stockConsolidationEmptySecurityIds; +void planSecurityIssuanceEmptyVestings; +void planSecurityTransferEmptyResults; diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 97f3e506..98b266d7 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -5,8 +5,10 @@ import { damlMonetaryToNative, ensureArray, monetaryToDaml, + nonEmptyArrayOrUndefined, normalizeNumericString, parseDamlMap, + toNonEmptyArray, } from '../../src/utils/typeConversions'; describe('normalizeNumericString', () => { @@ -109,6 +111,28 @@ describe('ensureArray', () => { }); }); +describe('non-empty array helpers', () => { + test('returns a non-empty tuple without changing its values', () => { + expect(toNonEmptyArray(['first', 'second'], 'items')).toEqual(['first', 'second']); + }); + + test('uses array length for cardinality even when the element type includes undefined', () => { + const values: Array = [undefined]; + expect(toNonEmptyArray(values, 'items')).toEqual([undefined]); + expect(nonEmptyArrayOrUndefined(values)).toEqual([undefined]); + }); + + test('rejects an empty required array with field context', () => { + expect(() => toNonEmptyArray([], 'transfer.resulting_security_ids')).toThrow(OcpValidationError); + expect(() => toNonEmptyArray([], 'transfer.resulting_security_ids')).toThrow('transfer.resulting_security_ids'); + }); + + test('converts optional arrays to a non-empty tuple or undefined', () => { + expect(nonEmptyArrayOrUndefined([])).toBeUndefined(); + expect(nonEmptyArrayOrUndefined(['only'])).toEqual(['only']); + }); +}); + describe('parseDamlMap', () => { describe('null/undefined handling', () => { test('returns empty array for null', () => { diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 1d098f15..958ad910 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -203,7 +203,16 @@ describe('DAML to OCF Validation', () => { name: 'Standard 4-year Vesting', description: 'Standard vesting with 1-year cliff', allocation_type: 'OcfAllocationCumulativeRounding', - vesting_conditions: [], + vesting_conditions: [ + { + id: 'condition-001', + description: null, + quantity: '1', + portion: null, + trigger: 'OcfVestingStartTrigger', + next_condition_ids: [], + }, + ], }; test('throws OcpValidationError when id is missing', async () => { From 3051f2578d99528ad4e5188f8cca532af5eb57c3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 18:18:18 -0400 Subject: [PATCH 03/10] Use canonical stock consolidation getter conversion --- .../getStockConsolidationAsOcf.ts | 25 ++------- .../stockClassAdjustmentConverters.test.ts | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts index e1621e01..04e5f6e1 100644 --- a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts +++ b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts @@ -1,18 +1,11 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; +import type { OcfStockConsolidation } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockConsolidationToNative } from './damlToStockConsolidation'; -export interface OcfStockConsolidationEvent { - object_type: 'TX_STOCK_CONSOLIDATION'; - id: string; - date: string; - security_ids: string[]; - resulting_security_id: string; - reason_text?: string; - comments?: string[]; -} +export type OcfStockConsolidationEvent = OcfStockConsolidation; export type GetStockConsolidationAsOcfParams = GetByContractIdParams; export interface GetStockConsolidationAsOcfResult { @@ -31,16 +24,6 @@ export async function getStockConsolidationAsOcf( operation: 'getStockConsolidationAsOcf', }); const contract = createArgument as StockConsolidationCreateArgument; - const data = contract.consolidation_data; - - const event: OcfStockConsolidationEvent = { - object_type: 'TX_STOCK_CONSOLIDATION', - id: data.id, - date: damlTimeToDateString(data.date, 'stockConsolidation.date'), - security_ids: data.security_ids, - resulting_security_id: data.resulting_security_id, - ...(data.reason_text ? { reason_text: data.reason_text } : {}), - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const event = damlStockConsolidationToNative(contract.consolidation_data); return { event, contractId: params.contractId }; } diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index a85188af..cc0ab927 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -14,10 +14,13 @@ * - StockReissuance */ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, type OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; import { damlStockConsolidationToNative } from '../../src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; +import { getStockConsolidationAsOcf } from '../../src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf'; import { damlStockReissuanceToNative } from '../../src/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; import type { OcfStockClassConversionRatioAdjustment, @@ -415,6 +418,58 @@ describe('Stock Class Adjustment Converters', () => { }); }); + describe('getStockConsolidationAsOcf', () => { + function clientWithSecurityIds(securityIds: string[]): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + createArgument: { + consolidation_data: { + id: 'consolidation-direct-001', + date: '2024-03-01T00:00:00.000Z', + security_ids: securityIds, + resulting_security_id: 'new-sec-direct-001', + reason_text: null, + comments: [], + }, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; + } + + test('returns the canonical non-empty stock consolidation shape', async () => { + const result = await getStockConsolidationAsOcf(clientWithSecurityIds(['sec-001', 'sec-002']), { + contractId: 'cid-stock-consolidation', + }); + + expect(result).toEqual({ + contractId: 'cid-stock-consolidation', + event: { + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'consolidation-direct-001', + date: '2024-03-01', + security_ids: ['sec-001', 'sec-002'], + resulting_security_id: 'new-sec-direct-001', + }, + }); + }); + + test('rejects an empty DAML security_ids list at the direct getter boundary', async () => { + await expect( + getStockConsolidationAsOcf(clientWithSecurityIds([]), { + contractId: 'cid-empty-stock-consolidation', + }) + ).rejects.toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockConsolidation.security_ids', + receivedValue: [], + } satisfies Partial); + }); + }); + describe('damlStockReissuanceToNative', () => { test('converts basic stock reissuance data', () => { const damlData = { From 3a28f42077aba9c4f01f63c663651a4887e75c22 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 22:17:41 -0400 Subject: [PATCH 04/10] fix: preserve transfer validation context --- src/utils/typeConversions.ts | 5 ++++- test/utils/typeConversions.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index efec0da8..fc172a14 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -565,12 +565,15 @@ export function quantityTransferToNative( d: DamlQuantityTransferData, dateFieldPath: string ): NativeQuantityTransferData { + const fieldPathSeparator = dateFieldPath.lastIndexOf('.'); + const fieldPathPrefix = fieldPathSeparator >= 0 ? dateFieldPath.slice(0, fieldPathSeparator + 1) : ''; + return { id: d.id, date: damlTimeToDateString(d.date, dateFieldPath), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), - resulting_security_ids: toNonEmptyArray(d.resulting_security_ids, 'transfer.resulting_security_ids'), + resulting_security_ids: toNonEmptyArray(d.resulting_security_ids, `${fieldPathPrefix}resulting_security_ids`), ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), ...(d.consideration_text ? { consideration_text: d.consideration_text } : {}), ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 98b266d7..4b966b91 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -8,6 +8,7 @@ import { nonEmptyArrayOrUndefined, normalizeNumericString, parseDamlMap, + quantityTransferToNative, toNonEmptyArray, } from '../../src/utils/typeConversions'; @@ -133,6 +134,31 @@ describe('non-empty array helpers', () => { }); }); +describe('quantityTransferToNative', () => { + it.each([ + ['stockTransfer.date', 'stockTransfer.resulting_security_ids'], + ['warrantTransfer.date', 'warrantTransfer.resulting_security_ids'], + ['equityCompensationTransfer.date', 'equityCompensationTransfer.resulting_security_ids'], + ])('reports empty results at the entity-specific path derived from %s', (dateFieldPath, expectedFieldPath) => { + try { + quantityTransferToNative( + { + id: 'transfer-1', + date: '2026-01-01T00:00:00Z', + security_id: 'security-1', + quantity: '1', + resulting_security_ids: [], + }, + dateFieldPath + ); + throw new Error('Expected quantityTransferToNative to reject an empty resulting_security_ids array'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).fieldPath).toBe(expectedFieldPath); + } + }); +}); + describe('parseDamlMap', () => { describe('null/undefined handling', () => { test('returns empty array for null', () => { From b6dfc713edf85bfdba4658685f866ed6cba0e541 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 22:18:30 -0400 Subject: [PATCH 05/10] test: cover transfer path fallback --- test/utils/typeConversions.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 4b966b91..d336b8a1 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -139,7 +139,8 @@ describe('quantityTransferToNative', () => { ['stockTransfer.date', 'stockTransfer.resulting_security_ids'], ['warrantTransfer.date', 'warrantTransfer.resulting_security_ids'], ['equityCompensationTransfer.date', 'equityCompensationTransfer.resulting_security_ids'], - ])('reports empty results at the entity-specific path derived from %s', (dateFieldPath, expectedFieldPath) => { + ['date', 'resulting_security_ids'], + ])('reports empty results at the sibling path derived from %s', (dateFieldPath, expectedFieldPath) => { try { quantityTransferToNative( { From 91a2f6f2776e654ee3ac45c3a5ade1602c09a29c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:21:41 -0400 Subject: [PATCH 06/10] fix: validate non-empty array containers --- .../getEquityCompensationIssuanceAsOcf.ts | 3 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 3 +- .../getWarrantIssuanceAsOcf.ts | 2 +- src/utils/typeConversions.ts | 20 +++++++-- test/batch/damlToOcfDispatcher.test.ts | 30 +++++++++++++- test/utils/typeConversions.test.ts | 41 +++++++++++++++++-- 6 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 0a6c0eb3..b52756a9 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -81,7 +81,8 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record | undefi amount: normalizeNumericString(amount), }; }); - return nonEmptyArrayOrUndefined(vestings); + return nonEmptyArrayOrUndefined(vestings, 'warrantIssuance.vestings'); } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 24dc361b..c10c8649 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -520,6 +520,13 @@ export function ensureArray(value: T[] | null | undefined): T[] { /** Return a non-empty tuple or fail when an external array violates its schema cardinality. */ export function toNonEmptyArray(values: readonly T[], fieldPath: string): NonEmptyArray { + if (!Array.isArray(values)) { + throw new OcpValidationError(fieldPath, 'Expected an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: values, + }); + } if (values.length === 0) { throw new OcpValidationError(fieldPath, 'Array must contain at least one item', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -527,14 +534,21 @@ export function toNonEmptyArray(values: readonly T[], fieldPath: string): Non receivedValue: values, }); } - const [first, ...rest] = values as readonly [T, ...T[]]; + const [first, ...rest] = values as unknown as readonly [T, ...T[]]; return [first, ...rest]; } /** Omit an optional array when empty; otherwise preserve its non-empty cardinality in the return type. */ -export function nonEmptyArrayOrUndefined(values: readonly T[]): NonEmptyArray | undefined { +export function nonEmptyArrayOrUndefined(values: readonly T[], fieldPath: string): NonEmptyArray | undefined { + if (!Array.isArray(values)) { + throw new OcpValidationError(fieldPath, 'Expected an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: values, + }); + } if (values.length === 0) return undefined; - const [first, ...rest] = values as readonly [T, ...T[]]; + const [first, ...rest] = values as unknown as readonly [T, ...T[]]; return [first, ...rest]; } diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 2dba5323..c5039175 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -4,7 +4,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { OcpContractError, OcpErrorCodes, OcpParseError, type OcpValidationError } from '../../src/errors'; import { ENTITY_REGISTRY, isOcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { convertToOcf, @@ -263,6 +263,34 @@ describe('damlToOcf dispatcher', () => { readAs: ['issuer::p'], }); }); + + it('rejects a malformed stock-transfer result container with field context', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue( + buildCreatedEventsResponse( + { + transfer_data: { + id: 'transfer-1', + date: '2026-01-01T00:00:00Z', + security_id: 'security-1', + quantity: '1', + resulting_security_ids: 'security-2', + balance_security_id: null, + consideration_text: null, + comments: [], + }, + }, + Fairmint.OpenCapTable.OCF.StockTransfer.StockTransfer.templateId + ) + ); + const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; + + await expect(getStockTransferAsOcf(mockClient, { contractId: 'transfer-cid' })).rejects.toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockTransfer.resulting_security_ids', + expectedType: 'array', + receivedValue: 'security-2', + } satisfies Partial); + }); }); describe('extractEntityData', () => { diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index ee5a2118..c6b871df 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -1,6 +1,6 @@ /** Unit tests for typeConversions utility functions. */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, @@ -121,7 +121,7 @@ describe('non-empty array helpers', () => { test('uses array length for cardinality even when the element type includes undefined', () => { const values: Array = [undefined]; expect(toNonEmptyArray(values, 'items')).toEqual([undefined]); - expect(nonEmptyArrayOrUndefined(values)).toEqual([undefined]); + expect(nonEmptyArrayOrUndefined(values, 'items')).toEqual([undefined]); }); test('rejects an empty required array with field context', () => { @@ -129,9 +129,42 @@ describe('non-empty array helpers', () => { expect(() => toNonEmptyArray([], 'transfer.resulting_security_ids')).toThrow('transfer.resulting_security_ids'); }); + test.each([ + ['string', 'not-an-array'], + ['null', null], + ['undefined', undefined], + ])('rejects a malformed %s container with a typed field error', (_name, values) => { + const invokeRequired = () => toNonEmptyArray(values as never, 'transfer.resulting_security_ids'); + const invokeOptional = () => nonEmptyArrayOrUndefined(values as never, 'issuance.vestings'); + + expect(invokeRequired).toThrow(OcpValidationError); + try { + invokeRequired(); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'transfer.resulting_security_ids', + expectedType: 'array', + receivedValue: values, + }); + } + + expect(invokeOptional).toThrow(OcpValidationError); + try { + invokeOptional(); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuance.vestings', + expectedType: 'array', + receivedValue: values, + }); + } + }); + test('converts optional arrays to a non-empty tuple or undefined', () => { - expect(nonEmptyArrayOrUndefined([])).toBeUndefined(); - expect(nonEmptyArrayOrUndefined(['only'])).toEqual(['only']); + expect(nonEmptyArrayOrUndefined([], 'items')).toBeUndefined(); + expect(nonEmptyArrayOrUndefined(['only'], 'items')).toEqual(['only']); }); }); From a0973148437e429c0cad192a29533271e74996ff Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:50:34 -0400 Subject: [PATCH 07/10] test: type non-empty conversion trigger fixture --- test/converters/convertibleIssuanceConverters.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 71e355ca..b6518ec4 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -12,7 +12,10 @@ */ import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; -import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { + convertibleIssuanceDataToDaml, + type ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import type { ConvertibleConversionTrigger } from '../../src/types/native'; import { requireFirst } from '../../src/utils/requireDefined'; @@ -258,7 +261,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => }); describe('convertible issuance seniority write boundary', () => { - const validInput = { + const validInput: ConvertibleIssuanceInput = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE], }; From b2cdf9043988296bfb1b8f7d8c4afc45ea060a6b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 21:49:37 -0400 Subject: [PATCH 08/10] Harden schema array cardinality boundaries --- package.json | 2 +- scripts/check-built-cardinality.cjs | 101 +++++++ .../convertibleTransferDataToDaml.ts | 26 +- .../convertibleTransfer/damlToOcf.ts | 8 +- .../getConvertibleTransferAsOcf.ts | 8 +- .../getEquityCompensationIssuanceAsOcf.ts | 74 ++--- .../equityCompensationTransferDataToDaml.ts | 20 +- .../getEquityCompensationTransferAsOcf.ts | 7 +- .../damlToStockConsolidation.ts | 39 ++- .../stockConsolidationDataToDaml.ts | 43 ++- .../stockIssuance/getStockIssuanceAsOcf.ts | 34 +-- .../stockTransfer/createStockTransfer.ts | 17 +- .../stockTransfer/getStockTransferAsOcf.ts | 6 +- .../getWarrantIssuanceAsOcf.ts | 10 +- .../getWarrantTransferAsOcf.ts | 8 +- .../warrantTransferDataToDaml.ts | 18 +- src/utils/arrayCardinality.ts | 239 ++++++++++++++++ src/utils/index.ts | 1 + src/utils/typeConversions.ts | 94 +++++-- .../equityCompensationPricing.test.ts | 56 ++++ .../schemaCardinalityBoundaries.test.ts | 266 ++++++++++++++++++ .../stockClassAdjustmentConverters.test.ts | 2 +- .../stockIssuanceReadConversions.test.ts | 6 +- test/declarations/nonEmptyArrays.types.ts | 12 + .../schemaAlignment/schemaConformance.test.ts | 13 + .../schemaConformanceHarness.ts | 78 +++++ .../schemaConformanceRegistry.ts | 30 ++ test/types/nonEmptyArrays.types.ts | 12 + test/utils/arrayCardinality.test.ts | 151 ++++++++++ test/utils/typeConversions.test.ts | 31 +- 30 files changed, 1242 insertions(+), 170 deletions(-) create mode 100644 scripts/check-built-cardinality.cjs create mode 100644 src/utils/arrayCardinality.ts create mode 100644 test/converters/schemaCardinalityBoundaries.test.ts create mode 100644 test/utils/arrayCardinality.test.ts diff --git a/package.json b/package.json index c47d3646..d95ab252 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "test": "npm run -s typecheck && jest --passWithNoTests", "test:ci": "npm run -s typecheck && jest --runInBand --coverage", "test:coverage": "jest --coverage --passWithNoTests", - "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config", + "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config && node scripts/check-built-cardinality.cjs", "test:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit", "test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests", "test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand", diff --git a/scripts/check-built-cardinality.cjs b/scripts/check-built-cardinality.cjs new file mode 100644 index 00000000..2824ce50 --- /dev/null +++ b/scripts/check-built-cardinality.cjs @@ -0,0 +1,101 @@ +'use strict'; + +const assert = require('node:assert/strict'); + +function loadBuiltModules() { + const originalConsoleDebug = console.debug; + const originalConsoleLog = console.log; + try { + console.debug = () => undefined; + console.log = () => undefined; + return { + errors: require('../dist/errors'), + readDispatcher: require('../dist/functions/OpenCapTable/capTable/damlToOcf'), + stockConsolidationWriter: require('../dist/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'), + stockTransferWriter: require('../dist/functions/OpenCapTable/stockTransfer/createStockTransfer'), + typeConversions: require('../dist/utils/typeConversions'), + }; + } finally { + console.debug = originalConsoleDebug; + console.log = originalConsoleLog; + } +} + +const built = loadBuiltModules(); +const { OcpErrorCodes, OcpValidationError } = built.errors; +const { convertToOcf } = built.readDispatcher; +const { stockConsolidationDataToDaml } = built.stockConsolidationWriter; +const { stockTransferDataToDaml } = built.stockTransferWriter; +const { toNonEmptyStringArray } = built.typeConversions; + +function expectValidationError(action, expectedPath, expectedCode) { + assert.throws(action, (error) => { + assert(error instanceof OcpValidationError); + assert.equal(error.fieldPath, expectedPath); + assert.equal(error.code, expectedCode); + return true; + }); +} + +expectValidationError( + () => toNonEmptyStringArray(['duplicate', 'duplicate'], 'items', { uniqueItems: true }), + 'items.1', + OcpErrorCodes.INVALID_FORMAT +); + +let trapCalls = 0; +const proxy = new Proxy(['secret'], { + get(target, property, receiver) { + trapCalls += 1; + return Reflect.get(target, property, receiver); + }, + ownKeys(target) { + trapCalls += 1; + return Reflect.ownKeys(target); + }, +}); +expectValidationError(() => toNonEmptyStringArray(proxy, 'items'), 'items', OcpErrorCodes.SCHEMA_MISMATCH); +assert.equal(trapCalls, 0); + +const transferDaml = { + balance_security_id: null, + comments: [], + consideration_text: null, + date: '2026-01-01T00:00:00.000Z', + id: 'transfer-1', + quantity: '1', + resulting_security_ids: ['duplicate', 'duplicate'], + security_id: 'security-1', +}; +expectValidationError( + () => convertToOcf('stockTransfer', transferDaml), + 'stockTransfer.resulting_security_ids.1', + OcpErrorCodes.INVALID_FORMAT +); + +expectValidationError( + () => + stockTransferDataToDaml({ + date: '2026-01-01', + id: 'transfer-1', + object_type: 'TX_STOCK_TRANSFER', + quantity: '1', + resulting_security_ids: ['duplicate', 'duplicate'], + security_id: 'security-1', + }), + 'stockTransfer.resulting_security_ids.1', + OcpErrorCodes.INVALID_FORMAT +); + +expectValidationError( + () => + stockConsolidationDataToDaml({ + date: '2026-01-01', + id: 'consolidation-1', + object_type: 'TX_STOCK_CONSOLIDATION', + security_ids: [], + resulting_security_id: 'result-1', + }), + 'stockConsolidation.security_ids', + OcpErrorCodes.OUT_OF_RANGE +); diff --git a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts index d1dbe9b8..79311c66 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts @@ -1,27 +1,33 @@ -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { OcpValidationError } from '../../../errors'; import type { OcfConvertibleTransfer } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, monetaryToDaml, optionalString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + monetaryToDaml, + optionalString, + toNonEmptyStringArray, +} from '../../../utils/typeConversions'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; export function convertibleTransferDataToDaml(d: OcfConvertibleTransfer): Record { + assertCanonicalJsonGraph(d, 'convertibleTransfer', { rejectUndefined: true }); if (!d.id) { throw new OcpValidationError('convertibleTransfer.id', 'Required field is missing or empty', { expectedType: 'string', receivedValue: d.id, }); } - // Validate required array field - if (d.resulting_security_ids.length === 0) { - throw new OcpValidationError('convertibleTransfer.resulting_security_ids', 'Must contain at least one element', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.resulting_security_ids, - }); - } + const resultingSecurityIds = toNonEmptyStringArray( + d.resulting_security_ids, + 'convertibleTransfer.resulting_security_ids', + { uniqueItems: true } + ); return { id: d.id, date: dateStringToDAMLTime(d.date, 'convertibleTransfer.date'), security_id: d.security_id, amount: monetaryToDaml(d.amount), - resulting_security_ids: d.resulting_security_ids, + resulting_security_ids: resultingSecurityIds, balance_security_id: optionalString(d.balance_security_id), consideration_text: optionalString(d.consideration_text), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts index 6690e3c7..7d31aa4f 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts @@ -3,7 +3,7 @@ */ import type { OcfConvertibleTransfer } from '../../../types'; -import { damlMonetaryToNative, damlTimeToDateString, toNonEmptyArray } from '../../../utils/typeConversions'; +import { damlMonetaryToNative, damlTimeToDateString, toNonEmptyStringArray } from '../../../utils/typeConversions'; /** * DAML ConvertibleTransfer data structure. @@ -33,7 +33,11 @@ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData): date: damlTimeToDateString(d.date, 'convertibleTransfer.date'), security_id: d.security_id, amount: damlMonetaryToNative(d.amount), - resulting_security_ids: toNonEmptyArray(d.resulting_security_ids, 'convertibleTransfer.resulting_security_ids'), + resulting_security_ids: toNonEmptyStringArray( + d.resulting_security_ids, + 'convertibleTransfer.resulting_security_ids', + { uniqueItems: true } + ), ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), ...(d.consideration_text ? { consideration_text: d.consideration_text } : {}), ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), diff --git a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts index 2a23d9f6..ef18b3b0 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyStringArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -48,7 +48,11 @@ export async function getConvertibleTransferAsOcf( amount: normalizeNumericString(amountStr), currency: data.amount.currency, }, - resulting_security_ids: toNonEmptyArray(data.resulting_security_ids, 'convertibleTransfer.resulting_security_ids'), + resulting_security_ids: toNonEmptyStringArray( + data.resulting_security_ids, + 'convertibleTransfer.resulting_security_ids', + { uniqueItems: true } + ), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 78991eb1..f268c7fc 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -7,6 +7,7 @@ import type { PeriodType, TerminationWindowReason, } from '../../../types/native'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlMonetaryToNativeWithValidation, damlTimeToDateString, @@ -112,43 +113,44 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { - if (!isRecord(v)) { - throw new OcpValidationError( - `equityCompensationIssuance.vestings[${index}]`, - `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: v, - } - ); - } + const vestings = + d.vestings === undefined + ? undefined + : (() => { + assertSafeGeneratedDamlJson(d.vestings, 'equityCompensationIssuance.vestings'); + return nonEmptyArrayOrUndefined(d.vestings, 'equityCompensationIssuance.vestings', (v, { index }) => { + if (!isRecord(v)) { + throw new OcpValidationError( + `equityCompensationIssuance.vestings[${index}]`, + `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: v, + } + ); + } - // Validate vesting amount - if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { - throw new OcpValidationError( - `equityCompensationIssuance.vestings[${index}].amount`, - `Must be string or number, got ${typeof v.amount}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: v.amount, - } - ); - } - // Convert to string after validation - const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; - return { - date: damlTimeToDateString(v.date, `equityCompensationIssuance.vestings[${index}].date`), - amount: normalizeNumericString(amountStr), - }; - }), - 'equityCompensationIssuance.vestings' - ) - : undefined; + // Validate vesting amount + if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { + throw new OcpValidationError( + `equityCompensationIssuance.vestings[${index}].amount`, + `Must be string or number, got ${typeof v.amount}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: v.amount, + } + ); + } + // Convert to string after validation + const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; + return { + date: damlTimeToDateString(v.date, `equityCompensationIssuance.vestings[${index}].date`), + amount: normalizeNumericString(amountStr), + }; + }); + })(); const terminationWindows = optionalCollection( d.termination_exercise_windows, diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts index 8e66aea9..93740988 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts @@ -1,33 +1,33 @@ -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { OcpValidationError } from '../../../errors'; import type { OcfEquityCompensationTransfer } from '../../../types'; import { cleanComments, dateStringToDAMLTime, normalizeNumericString, optionalString, + toNonEmptyStringArray, } from '../../../utils/typeConversions'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; export function equityCompensationTransferDataToDaml(d: OcfEquityCompensationTransfer): Record { + assertCanonicalJsonGraph(d, 'equityCompensationTransfer', { rejectUndefined: true }); if (!d.id) { throw new OcpValidationError('equityCompensationTransfer.id', 'Required field is missing or empty', { expectedType: 'string', receivedValue: d.id, }); } - // Validate required array field - if (d.resulting_security_ids.length === 0) { - throw new OcpValidationError( - 'equityCompensationTransfer.resulting_security_ids', - 'Must contain at least one element', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: d.resulting_security_ids } - ); - } + const resultingSecurityIds = toNonEmptyStringArray( + d.resulting_security_ids, + 'equityCompensationTransfer.resulting_security_ids', + { uniqueItems: true } + ); return { id: d.id, date: dateStringToDAMLTime(d.date, 'equityCompensationTransfer.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), - resulting_security_ids: d.resulting_security_ids, + resulting_security_ids: resultingSecurityIds, balance_security_id: optionalString(d.balance_security_id), consideration_text: optionalString(d.consideration_text), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts b/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts index ac0ea881..404cdb51 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfEquityCompensationTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyStringArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -46,9 +46,10 @@ export async function getEquityCompensationTransferAsOcf( date: damlTimeToDateString(data.date, 'equityCompensationTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), - resulting_security_ids: toNonEmptyArray( + resulting_security_ids: toNonEmptyStringArray( data.resulting_security_ids, - 'equityCompensationTransfer.resulting_security_ids' + 'equityCompensationTransfer.resulting_security_ids', + { uniqueItems: true } ), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), diff --git a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index d01f95a6..e85901bb 100644 --- a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts +++ b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts @@ -3,7 +3,14 @@ */ import type { OcfStockConsolidation } from '../../../types/native'; -import { damlTimeToDateString, toNonEmptyArray } from '../../../utils/typeConversions'; +import { + assertSafeGeneratedDamlJson, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; +import { damlTimeToDateString, toNonEmptyStringArray } from '../../../utils/typeConversions'; /** DAML StockConsolidationOcfData structure */ export interface DamlStockConsolidationData { @@ -21,13 +28,31 @@ export interface DamlStockConsolidationData { * Converts DAML StockConsolidation data to canonical OCF format. */ export function damlStockConsolidationToNative(d: DamlStockConsolidationData): OcfStockConsolidation { + const rootPath = 'stockConsolidation'; + assertSafeGeneratedDamlJson(d, rootPath); + const data = requireGeneratedRecord(d, rootPath); + rejectUnknownGeneratedFields(data, rootPath, [ + 'comments', + 'date', + 'id', + 'reason_text', + 'resulting_security_id', + 'security_ids', + ]); + const id = requireGeneratedString(data.id, `${rootPath}.id`); + const date = requireGeneratedString(data.date, `${rootPath}.date`); + const resultingSecurityId = requireGeneratedString(data.resulting_security_id, `${rootPath}.resulting_security_id`); + const comments = requireGeneratedStringArray(data.comments, `${rootPath}.comments`); + const reasonText = + data.reason_text === null ? null : requireGeneratedString(data.reason_text, `${rootPath}.reason_text`); + return { object_type: 'TX_STOCK_CONSOLIDATION', - id: d.id, - date: damlTimeToDateString(d.date, 'stockConsolidation.date'), - security_ids: toNonEmptyArray(d.security_ids, 'stockConsolidation.security_ids'), - resulting_security_id: d.resulting_security_id, - ...(d.reason_text ? { reason_text: d.reason_text } : {}), - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + id, + date: damlTimeToDateString(date, `${rootPath}.date`), + security_ids: toNonEmptyStringArray(data.security_ids, `${rootPath}.security_ids`, { uniqueItems: true }), + resulting_security_id: resultingSecurityId, + ...(reasonText ? { reason_text: reasonText } : {}), + ...(comments.length > 0 ? { comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts b/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts index 097a592b..fd003803 100644 --- a/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts +++ b/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts @@ -2,9 +2,16 @@ * OCF to DAML converter for StockConsolidation. */ -import { OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockConsolidation } from '../../../types/native'; -import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + isRecord, + optionalString, + toNonEmptyStringArray, +} from '../../../utils/typeConversions'; +import { assertCanonicalJsonGraph, assertExactObjectFields } from '../shared/ocfValues'; /** * Convert native OCF StockConsolidation data to DAML format. @@ -12,17 +19,45 @@ import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../ut * Both canonical OCF and DAML use resulting_security_id (singular). */ export function stockConsolidationDataToDaml(d: OcfStockConsolidation): Record { + const runtimeValue: unknown = d; + assertCanonicalJsonGraph(runtimeValue, 'stockConsolidation', { rejectUndefined: true }); + if (!isRecord(runtimeValue)) { + throw new OcpValidationError('stockConsolidation', 'Stock consolidation must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: runtimeValue, + }); + } + assertExactObjectFields( + runtimeValue, + ['comments', 'date', 'id', 'object_type', 'reason_text', 'resulting_security_id', 'security_ids'], + 'stockConsolidation' + ); if (!d.id) { throw new OcpValidationError('stockConsolidation.id', 'Required field is missing or empty', { expectedType: 'string', receivedValue: d.id, }); } + const resultingSecurityId: unknown = d.resulting_security_id; + if (typeof resultingSecurityId !== 'string') { + throw new OcpValidationError('stockConsolidation.resulting_security_id', 'resulting_security_id must be a string', { + code: + resultingSecurityId === undefined || resultingSecurityId === null + ? OcpErrorCodes.REQUIRED_FIELD_MISSING + : OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: resultingSecurityId, + }); + } + const securityIds = toNonEmptyStringArray(d.security_ids, 'stockConsolidation.security_ids', { + uniqueItems: true, + }); return { id: d.id, date: dateStringToDAMLTime(d.date, 'stockConsolidation.date'), - security_ids: d.security_ids, - resulting_security_id: d.resulting_security_id, + security_ids: securityIds, + resulting_security_id: resultingSecurityId, reason_text: optionalString(d.reason_text), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index 14930543..0b778c0e 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,6 +3,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlMonetaryToNative, damlTimeToDateString, @@ -157,26 +158,19 @@ export function damlStockIssuanceDataToNative( const stakeholderId = requireStockIssuanceString(anyD, 'stakeholder_id'); const stockClassId = requireStockIssuanceString(anyD, 'stock_class_id'); const vestingInputs = anyD.vestings; - if (vestingInputs !== undefined && !Array.isArray(vestingInputs)) { - throw new OcpParseError('StockIssuance vestings must be an array', { - source: 'stockIssuance.vestings', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'invalid_stock_issuance_vestings_shape', - context: { receivedType: vestingInputs === null ? 'null' : typeof vestingInputs }, - }); - } - const vestings = nonEmptyArrayOrUndefined( - Array.isArray(vestingInputs) - ? vestingInputs.map((input, index) => { - const vesting = decodeStockIssuanceVesting(input, index); - return { - date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - }; - }) - : [], - 'stockIssuance.vestings' - ); + const vestings = + vestingInputs === undefined + ? undefined + : (() => { + assertSafeGeneratedDamlJson(vestingInputs, 'stockIssuance.vestings'); + return nonEmptyArrayOrUndefined(vestingInputs, 'stockIssuance.vestings', (input, { index }) => { + const vesting = decodeStockIssuanceVesting(input, index); + return { + date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), + }; + }); + })(); const issuanceType = damlStockIssuanceTypeToNative(anyD.issuance_type); const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockIssuance.board_approval_date'); diff --git a/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts b/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts index 51db5f89..1617c0e5 100644 --- a/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts +++ b/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts @@ -1,27 +1,24 @@ -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockTransfer } from '../../../types'; import { cleanComments, dateStringToDAMLTime, normalizeNumericString, optionalString, + toNonEmptyStringArray, } from '../../../utils/typeConversions'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; export function stockTransferDataToDaml(d: OcfStockTransfer): Record { - // Validate required array field - if (d.resulting_security_ids.length === 0) { - throw new OcpValidationError( - 'stockTransfer.resulting_security_ids', - 'resulting_security_ids must contain at least one element', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: d.resulting_security_ids } - ); - } + assertCanonicalJsonGraph(d, 'stockTransfer', { rejectUndefined: true }); + const resultingSecurityIds = toNonEmptyStringArray(d.resulting_security_ids, 'stockTransfer.resulting_security_ids', { + uniqueItems: true, + }); return { id: d.id, security_id: d.security_id, date: dateStringToDAMLTime(d.date, 'stockTransfer.date'), quantity: normalizeNumericString(d.quantity), - resulting_security_ids: d.resulting_security_ids, + resulting_security_ids: resultingSecurityIds, balance_security_id: optionalString(d.balance_security_id), consideration_text: optionalString(d.consideration_text), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts b/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts index 2916e0ea..049db517 100644 --- a/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts +++ b/src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyStringArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -45,7 +45,9 @@ export async function getStockTransferAsOcf( date: damlTimeToDateString(data.date, 'stockTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), - resulting_security_ids: toNonEmptyArray(data.resulting_security_ids, 'stockTransfer.resulting_security_ids'), + resulting_security_ids: toNonEmptyStringArray(data.resulting_security_ids, 'stockTransfer.resulting_security_ids', { + uniqueItems: true, + }), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 72476d7b..3bf8f31f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -15,6 +15,7 @@ import type { WarrantTriggerConversionRight, } from '../../../types/native'; import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, isRecord, @@ -293,11 +294,9 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined } function vestingsFromDaml(value: unknown): NonEmptyArray | undefined { - if (value === null || value === undefined) return undefined; - if (!Array.isArray(value)) { - throw invalidType('warrantIssuance.vestings', 'vestings must be an array', 'array', value); - } - const vestings = value.map((item, index) => { + if (value === undefined) return undefined; + assertSafeGeneratedDamlJson(value, 'warrantIssuance.vestings'); + return nonEmptyArrayOrUndefined(value, 'warrantIssuance.vestings', (item, { index }) => { const field = `warrantIssuance.vestings.${index}`; if (!isRecord(item)) { throw invalidType(field, `${field} must be an object`, 'object', item); @@ -309,7 +308,6 @@ function vestingsFromDaml(value: unknown): NonEmptyArray | undefi amount: normalizedAmount, }; }); - return nonEmptyArrayOrUndefined(vestings, 'warrantIssuance.vestings'); } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { diff --git a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts index 232554a0..28186351 100644 --- a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts +++ b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfWarrantTransfer } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyStringArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -45,7 +45,11 @@ export async function getWarrantTransferAsOcf( date: damlTimeToDateString(data.date, 'warrantTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), - resulting_security_ids: toNonEmptyArray(data.resulting_security_ids, 'warrantTransfer.resulting_security_ids'), + resulting_security_ids: toNonEmptyStringArray( + data.resulting_security_ids, + 'warrantTransfer.resulting_security_ids', + { uniqueItems: true } + ), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), diff --git a/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts b/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts index d9a9f669..c8af2a8c 100644 --- a/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts @@ -5,9 +5,12 @@ import { dateStringToDAMLTime, normalizeNumericString, optionalString, + toNonEmptyStringArray, } from '../../../utils/typeConversions'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; export function warrantTransferDataToDaml(d: OcfWarrantTransfer): Record { + assertCanonicalJsonGraph(d, 'warrantTransfer', { rejectUndefined: true }); if (!d.id) { throw new OcpValidationError('warrantTransfer.id', 'Required field is missing or empty', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -15,20 +18,17 @@ export function warrantTransferDataToDaml(d: OcfWarrantTransfer): Record = (value: unknown, context: ArrayItemContext) => T; + +export interface ArrayCardinality { + readonly maximum?: number; + readonly minimum?: number; +} + +export interface ArrayUniqueness { + readonly key: (value: T) => string | number; + readonly expectedType?: string; +} + +export interface ParseArraySnapshotOptions { + readonly cardinality?: ArrayCardinality; + readonly item: ArrayItemParser; + readonly uniqueness?: ArrayUniqueness; +} + +function invalidArrayShape( + fieldPath: string, + message: string, + expectedType: string, + receivedValue: unknown +): OcpValidationError { + return new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue, + }); +} + +function validateCardinalityConfiguration(cardinality: ArrayCardinality): void { + const { maximum, minimum } = cardinality; + if (minimum !== undefined && (!Number.isSafeInteger(minimum) || minimum < 0)) { + throw new Error('Array minimum must be a non-negative safe integer'); + } + if (maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0)) { + throw new Error('Array maximum must be a non-negative safe integer'); + } + if (minimum !== undefined && maximum !== undefined && minimum > maximum) { + throw new Error('Array minimum must not exceed its maximum'); + } +} + +/** + * Copy an untrusted array without invoking Proxy traps or element accessors. + * + * The returned array is always a fresh, dense, ordinary Array. Item validation, + * cardinality, and schema uniqueness are applied before the snapshot is exposed. + */ +export function parseArraySnapshot(value: unknown, fieldPath: string, options: ParseArraySnapshotOptions): T[] { + const cardinality = options.cardinality ?? {}; + validateCardinalityConfiguration(cardinality); + + if (nodeUtilTypes.isProxy(value)) { + throw invalidArrayShape( + fieldPath, + `${fieldPath} cannot be a JavaScript Proxy`, + 'ordinary JSON array', + 'JavaScript Proxy' + ); + } + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'array', + receivedValue: value, + }); + } + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be an array`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: value, + }); + } + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw invalidArrayShape( + fieldPath, + `${fieldPath} must use the canonical Array prototype`, + 'ordinary JSON array', + 'custom array prototype' + ); + } + + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) { + throw invalidArrayShape( + diagnosticPropertyPath(fieldPath, 'length'), + `${fieldPath}.length must be an own data property`, + 'own array length data property', + 'missing or accessor length property' + ); + } + const length = lengthDescriptor.value as number; + const itemDescriptors = new Map(); + + for (const key of Object.getOwnPropertyNames(value)) { + if (key === 'length') continue; + const propertyPath = diagnosticPropertyPath(fieldPath, key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw invalidArrayShape( + propertyPath, + `${propertyPath} must be an own data property`, + 'own array item data property', + 'accessor property' + ); + } + if (descriptor.enumerable !== true) { + throw invalidArrayShape( + propertyPath, + `${propertyPath} must be enumerable`, + 'enumerable own array item data property', + 'non-enumerable property' + ); + } + + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { + throw invalidArrayShape( + propertyPath, + `${propertyPath} is not a canonical array index`, + 'array index or length only', + descriptor.value + ); + } + itemDescriptors.set(index, descriptor); + } + + const symbol = Object.getOwnPropertySymbols(value)[0]; + if (symbol !== undefined) { + const propertyPath = diagnosticPropertyPath(fieldPath, symbol); + throw invalidArrayShape( + propertyPath, + `${propertyPath} is not supported on an OCF array`, + 'array without symbol properties', + symbol + ); + } + + if (itemDescriptors.size !== length) { + let missingIndex = 0; + while (itemDescriptors.has(missingIndex)) missingIndex += 1; + const itemPath = diagnosticPropertyPath(fieldPath, String(missingIndex)); + throw new OcpValidationError(itemPath, `${itemPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'own array item data property', + receivedValue: undefined, + }); + } + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + const propertyPath = diagnosticPropertyPath(fieldPath, key); + throw invalidArrayShape( + propertyPath, + `${propertyPath} is inherited rather than own`, + 'own array index', + 'inherited property' + ); + } + } + + const { maximum, minimum } = cardinality; + if (minimum !== undefined && length < minimum) { + throw new OcpValidationError( + fieldPath, + `${fieldPath} must contain at least ${minimum} item${minimum === 1 ? '' : 's'}`, + { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: `array with at least ${minimum} item${minimum === 1 ? '' : 's'}`, + receivedValue: value, + context: { actualItems: length, minimumItems: minimum }, + } + ); + } + if (maximum !== undefined && length > maximum) { + throw new OcpValidationError( + fieldPath, + `${fieldPath} must contain at most ${maximum} item${maximum === 1 ? '' : 's'}`, + { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: `array with at most ${maximum} item${maximum === 1 ? '' : 's'}`, + receivedValue: value, + context: { actualItems: length, maximumItems: maximum }, + } + ); + } + + const parsed: T[] = []; + const firstIndexByKey = new Map(); + for (let index = 0; index < length; index += 1) { + const descriptor = itemDescriptors.get(index); + if (descriptor === undefined || !('value' in descriptor)) { + throw new Error(`Dense array descriptor disappeared at index ${index}`); + } + const itemPath = diagnosticPropertyPath(fieldPath, String(index)); + const item = options.item(descriptor.value, { fieldPath: itemPath, index }); + if (options.uniqueness !== undefined) { + const key = options.uniqueness.key(item); + const firstIndex = firstIndexByKey.get(key); + if (firstIndex !== undefined) { + throw new OcpValidationError(itemPath, `${itemPath} duplicates array item ${firstIndex}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: options.uniqueness.expectedType ?? 'unique array item', + receivedValue: item, + context: { duplicateIndex: index, duplicateOfIndex: firstIndex }, + }); + } + firstIndexByKey.set(key, index); + } + parsed.push(item); + } + + return parsed; +} + +/** Validate one exact string array item without trimming or coercion. */ +export function parseStringArrayItem(value: unknown, context: ArrayItemContext): string { + if (typeof value !== 'string') { + throw new OcpValidationError(context.fieldPath, `${context.fieldPath} must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + context: { index: context.index }, + }); + } + return value; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 79d35216..0d451737 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './arrayCardinality'; export * from './cantonOcfExtractor'; export * from './contractReadDiagnostics'; export * from './entityValidators'; diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 85a1c55f..d0749039 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -7,6 +7,12 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../errors'; import type { Address, AddressType, ConversionTriggerType, Monetary, NonEmptyArray } from '../types/native'; +import { + parseArraySnapshot, + parseStringArrayItem, + type ArrayItemParser, + type ArrayUniqueness, +} from './arrayCardinality'; import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; // Public conversion helpers use stable structural wire shapes. Generated DAML @@ -608,38 +614,60 @@ export function ensureArray(value: T[] | null | undefined): T[] { return []; } -/** Return a non-empty tuple or fail when an external array violates its schema cardinality. */ -export function toNonEmptyArray(values: readonly T[], fieldPath: string): NonEmptyArray { - if (!Array.isArray(values)) { - throw new OcpValidationError(fieldPath, 'Expected an array', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'array', - receivedValue: values, - }); - } - if (values.length === 0) { - throw new OcpValidationError(fieldPath, 'Array must contain at least one item', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'array with at least one item', - receivedValue: values, - }); - } - const [first, ...rest] = values as unknown as readonly [T, ...T[]]; - return [first, ...rest]; +export interface NonEmptyArrayConversionOptions { + readonly maximum?: number; + readonly uniqueness?: ArrayUniqueness; } -/** Omit an optional array when empty; otherwise preserve its non-empty cardinality in the return type. */ -export function nonEmptyArrayOrUndefined(values: readonly T[], fieldPath: string): NonEmptyArray | undefined { - if (!Array.isArray(values)) { - throw new OcpValidationError(fieldPath, 'Expected an array', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'array', - receivedValue: values, - }); - } - if (values.length === 0) return undefined; - const [first, ...rest] = values as unknown as readonly [T, ...T[]]; - return [first, ...rest]; +function nonEmptySnapshot(values: readonly T[]): NonEmptyArray { + const iterator = values[Symbol.iterator](); + const first = iterator.next(); + if (first.done === true) throw new Error('Non-empty array parser returned no first item'); + return [first.value, ...iterator]; +} + +/** Return a validated non-empty snapshot of an untrusted external array. */ +export function toNonEmptyArray( + values: unknown, + fieldPath: string, + item: ArrayItemParser, + options: NonEmptyArrayConversionOptions = {} +): NonEmptyArray { + const parsed = parseArraySnapshot(values, fieldPath, { + cardinality: { minimum: 1, ...(options.maximum === undefined ? {} : { maximum: options.maximum }) }, + item, + ...(options.uniqueness === undefined ? {} : { uniqueness: options.uniqueness }), + }); + return nonEmptySnapshot(parsed); +} + +/** Omit a ledger-encoded optional array when empty; otherwise return a validated non-empty snapshot. */ +export function nonEmptyArrayOrUndefined( + values: unknown, + fieldPath: string, + item: ArrayItemParser, + options: NonEmptyArrayConversionOptions = {} +): NonEmptyArray | undefined { + const parsed = parseArraySnapshot(values, fieldPath, { + cardinality: options.maximum === undefined ? {} : { maximum: options.maximum }, + item, + ...(options.uniqueness === undefined ? {} : { uniqueness: options.uniqueness }), + }); + if (parsed.length === 0) return undefined; + return nonEmptySnapshot(parsed); +} + +/** Validate a schema-non-empty string array, optionally enforcing JSON Schema uniqueItems. */ +export function toNonEmptyStringArray( + values: unknown, + fieldPath: string, + options: { readonly uniqueItems?: boolean } = {} +): NonEmptyArray { + return toNonEmptyArray(values, fieldPath, parseStringArrayItem, { + ...(options.uniqueItems === true + ? { uniqueness: { expectedType: 'unique string array item', key: (value: string) => value } } + : {}), + }); } /** @@ -711,7 +739,11 @@ export function quantityTransferToNative( date: damlTimeToDateString(d.date, dateFieldPath), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), - resulting_security_ids: toNonEmptyArray(d.resulting_security_ids, `${fieldPathPrefix}resulting_security_ids`), + resulting_security_ids: toNonEmptyStringArray( + d.resulting_security_ids, + `${fieldPathPrefix}resulting_security_ids`, + { uniqueItems: true } + ), ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), ...(d.consideration_text ? { consideration_text: d.consideration_text } : {}), ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index a96060f0..9d26f927 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -235,4 +235,60 @@ describe('equity compensation ledger pricing boundary', () => { compensation_type: 'RSU', }); }); + + it('decodes DAML [] vestings as omission and preserves non-empty vestings', () => { + const base = { + ...ledgerIssuanceBase, + compensation_type: 'OcfCompensationTypeRSU', + exercise_price: null, + base_price: null, + }; + expect(damlEquityCompensationIssuanceDataToNative({ ...base, vestings: [] })).not.toHaveProperty('vestings'); + expect( + damlEquityCompensationIssuanceDataToNative({ + ...base, + vestings: [{ amount: '10.00', date: '2026-02-01T00:00:00.000Z' }], + }).vestings + ).toEqual([{ amount: '10', date: '2026-02-01' }]); + }); + + it.each([null, 'not-an-array', { 0: 'not-an-array' }])( + 'rejects malformed present DAML vestings container %p', + (vestings) => { + expect(() => + damlEquityCompensationIssuanceDataToNative({ + ...ledgerIssuanceBase, + compensation_type: 'OcfCompensationTypeRSU', + exercise_price: null, + base_price: null, + vestings, + }) + ).toThrow(); + } + ); + + it('rejects an accessor vesting without invoking its getter', () => { + let getterReads = 0; + const vestings: unknown[] = []; + Object.defineProperty(vestings, '0', { + configurable: true, + enumerable: true, + get() { + getterReads += 1; + return { amount: '10', date: '2026-02-01T00:00:00.000Z' }; + }, + }); + vestings.length = 1; + + expect(() => + damlEquityCompensationIssuanceDataToNative({ + ...ledgerIssuanceBase, + compensation_type: 'OcfCompensationTypeRSU', + exercise_price: null, + base_price: null, + vestings, + }) + ).toThrow(); + expect(getterReads).toBe(0); + }); }); diff --git a/test/converters/schemaCardinalityBoundaries.test.ts b/test/converters/schemaCardinalityBoundaries.test.ts new file mode 100644 index 00000000..5ff68cc7 --- /dev/null +++ b/test/converters/schemaCardinalityBoundaries.test.ts @@ -0,0 +1,266 @@ +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertibleTransferDataToDaml } from '../../src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml'; +import { damlConvertibleTransferToNative } from '../../src/functions/OpenCapTable/convertibleTransfer/damlToOcf'; +import { damlEquityCompensationTransferToNative } from '../../src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf'; +import { equityCompensationTransferDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml'; +import { damlStockConsolidationToNative } from '../../src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; +import { stockConsolidationDataToDaml } from '../../src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'; +import { stockTransferDataToDaml } from '../../src/functions/OpenCapTable/stockTransfer/createStockTransfer'; +import { damlStockTransferToNative } from '../../src/functions/OpenCapTable/stockTransfer/damlToOcf'; +import { damlWarrantTransferToNative } from '../../src/functions/OpenCapTable/warrantTransfer/damlToOcf'; +import { warrantTransferDataToDaml } from '../../src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml'; + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected schema cardinality validation to fail'); +} + +const QUANTITY_DAML = { + balance_security_id: undefined, + comments: [], + consideration_text: undefined, + date: '2026-01-01T00:00:00.000Z', + id: 'transfer-1', + quantity: '1', + resulting_security_ids: ['result-1'], + security_id: 'security-1', +}; + +const QUANTITY_OCF = { + date: '2026-01-01', + id: 'transfer-1', + quantity: '1', + resulting_security_ids: ['result-1'], + security_id: 'security-1', +}; + +const transferCases = [ + { + genericRead: (resultingSecurityIds: unknown) => + convertToOcf('stockTransfer', { ...QUANTITY_DAML, resulting_security_ids: resultingSecurityIds } as never), + label: 'stock transfer', + path: 'stockTransfer.resulting_security_ids', + read: (resultingSecurityIds: unknown) => + damlStockTransferToNative({ ...QUANTITY_DAML, resulting_security_ids: resultingSecurityIds } as never), + write: (resultingSecurityIds: unknown) => + stockTransferDataToDaml({ + ...QUANTITY_OCF, + object_type: 'TX_STOCK_TRANSFER', + resulting_security_ids: resultingSecurityIds, + } as never), + }, + { + genericRead: (resultingSecurityIds: unknown) => + convertToOcf('warrantTransfer', { ...QUANTITY_DAML, resulting_security_ids: resultingSecurityIds } as never), + label: 'warrant transfer', + path: 'warrantTransfer.resulting_security_ids', + read: (resultingSecurityIds: unknown) => + damlWarrantTransferToNative({ ...QUANTITY_DAML, resulting_security_ids: resultingSecurityIds } as never), + write: (resultingSecurityIds: unknown) => + warrantTransferDataToDaml({ + ...QUANTITY_OCF, + object_type: 'TX_WARRANT_TRANSFER', + resulting_security_ids: resultingSecurityIds, + } as never), + }, + { + genericRead: (resultingSecurityIds: unknown) => + convertToOcf('equityCompensationTransfer', { + ...QUANTITY_DAML, + resulting_security_ids: resultingSecurityIds, + } as never), + label: 'equity compensation transfer', + path: 'equityCompensationTransfer.resulting_security_ids', + read: (resultingSecurityIds: unknown) => + damlEquityCompensationTransferToNative({ + ...QUANTITY_DAML, + resulting_security_ids: resultingSecurityIds, + } as never), + write: (resultingSecurityIds: unknown) => + equityCompensationTransferDataToDaml({ + ...QUANTITY_OCF, + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', + resulting_security_ids: resultingSecurityIds, + } as never), + }, + { + genericRead: (resultingSecurityIds: unknown) => + convertToOcf('convertibleTransfer', { + ...QUANTITY_DAML, + amount: { amount: '1', currency: 'USD' }, + resulting_security_ids: resultingSecurityIds, + } as never), + label: 'convertible transfer', + path: 'convertibleTransfer.resulting_security_ids', + read: (resultingSecurityIds: unknown) => + damlConvertibleTransferToNative({ + ...QUANTITY_DAML, + amount: { amount: '1', currency: 'USD' }, + resulting_security_ids: resultingSecurityIds, + } as never), + write: (resultingSecurityIds: unknown) => + convertibleTransferDataToDaml({ + date: QUANTITY_OCF.date, + id: QUANTITY_OCF.id, + object_type: 'TX_CONVERTIBLE_TRANSFER', + amount: { amount: '1', currency: 'USD' }, + resulting_security_ids: resultingSecurityIds, + security_id: QUANTITY_OCF.security_id, + } as never), + }, +] as const; + +describe('schema-cardinality transfer boundaries', () => { + it.each(transferCases)( + '$label readers reject duplicate uniqueItems at the duplicate index', + ({ genericRead, path, read }) => { + for (const invoke of [read, genericRead]) { + const error = captureValidationError(() => invoke(['duplicate', 'duplicate'])); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${path}.1`, + receivedValue: 'duplicate', + }); + expect(error.context).toMatchObject({ duplicateIndex: 1, duplicateOfIndex: 0 }); + } + } + ); + + it.each(transferCases)('$label writers reject duplicate uniqueItems at the duplicate index', ({ path, write }) => { + const error = captureValidationError(() => write(['duplicate', 'duplicate'])); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${path}.1`, + receivedValue: 'duplicate', + }); + }); + + it.each(transferCases)('$label boundaries reject wrong element types at the exact index', ({ path, read, write }) => { + for (const invoke of [read, write]) { + const error = captureValidationError(() => invoke(['valid', 42])); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + fieldPath: `${path}.1`, + receivedValue: 42, + }); + } + }); + + it.each(transferCases)('$label boundaries reject empty required arrays as out of range', ({ path, read, write }) => { + for (const invoke of [read, write]) { + const error = captureValidationError(() => invoke([])); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: path, + receivedValue: [], + }); + } + }); +}); + +const CONSOLIDATION_DAML = { + comments: [], + date: '2026-01-01T00:00:00.000Z', + id: 'consolidation-1', + reason_text: null, + resulting_security_id: 'result-1', + security_ids: ['security-1'], +}; + +const CONSOLIDATION_OCF = { + date: '2026-01-01', + id: 'consolidation-1', + object_type: 'TX_STOCK_CONSOLIDATION' as const, + resulting_security_id: 'result-1', + security_ids: ['security-1'], +}; + +describe('schema-cardinality stock consolidation boundaries', () => { + it.each([ + { + invoke: (securityIds: unknown) => + damlStockConsolidationToNative({ ...CONSOLIDATION_DAML, security_ids: securityIds } as never), + label: 'direct reader', + }, + { + invoke: (securityIds: unknown) => + convertToOcf('stockConsolidation', { ...CONSOLIDATION_DAML, security_ids: securityIds } as never), + label: 'generic reader', + }, + { + invoke: (securityIds: unknown) => + stockConsolidationDataToDaml({ ...CONSOLIDATION_OCF, security_ids: securityIds } as never), + label: 'writer', + }, + ])('$label rejects empty, duplicate, and wrong-type security_ids', ({ invoke }) => { + expect(captureValidationError(() => invoke([]))).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockConsolidation.security_ids', + }); + expect(captureValidationError(() => invoke(['duplicate', 'duplicate']))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockConsolidation.security_ids.1', + receivedValue: 'duplicate', + }); + expect(captureValidationError(() => invoke(['valid', 42]))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockConsolidation.security_ids.1', + receivedValue: 42, + }); + }); + + it('reader requires canonical singular resulting_security_id and rejects legacy plural data', () => { + const { resulting_security_id: _resultingSecurityId, ...withoutSingular } = CONSOLIDATION_DAML; + expect(() => damlStockConsolidationToNative(withoutSingular as never)).toThrow(OcpParseError); + try { + damlStockConsolidationToNative(withoutSingular as never); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockConsolidation.resulting_security_id', + }); + } + + expect(() => + damlStockConsolidationToNative({ + ...CONSOLIDATION_DAML, + resulting_security_ids: ['legacy-result'], + } as never) + ).toThrow(OcpParseError); + try { + damlStockConsolidationToNative({ + ...CONSOLIDATION_DAML, + resulting_security_ids: ['legacy-result'], + } as never); + } catch (error) { + expect(error).toMatchObject({ source: 'stockConsolidation.resulting_security_ids' }); + } + }); + + it('writer requires canonical singular resulting_security_id and rejects legacy plural data', () => { + const { resulting_security_id: _resultingSecurityId, ...withoutSingular } = CONSOLIDATION_OCF; + expect(captureValidationError(() => stockConsolidationDataToDaml(withoutSingular as never))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockConsolidation.resulting_security_id', + }); + + expect( + captureValidationError(() => + stockConsolidationDataToDaml({ + ...CONSOLIDATION_OCF, + resulting_security_ids: ['legacy-result'], + } as never) + ) + ).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockConsolidation.resulting_security_ids', + }); + }); +}); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 6d1cae15..9654cda9 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -950,7 +950,7 @@ describe('Stock Class Adjustment Converters', () => { contractId: 'cid-empty-stock-consolidation', }) ).rejects.toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: 'stockConsolidation.security_ids', receivedValue: [], } satisfies Partial); diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index 80b502d7..c38c1a48 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -218,10 +218,10 @@ describe('damlStockIssuanceDataToNative', () => { damlStockIssuanceDataToNative(daml as Parameters[0]); throw new Error('Expected stock issuance vestings container validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); + expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'stockIssuance.vestings', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockIssuance.vestings', }); } }); diff --git a/test/declarations/nonEmptyArrays.types.ts b/test/declarations/nonEmptyArrays.types.ts index 92ba6252..95a3676a 100644 --- a/test/declarations/nonEmptyArrays.types.ts +++ b/test/declarations/nonEmptyArrays.types.ts @@ -1,4 +1,5 @@ import type { + NonEmptyArray, OcfConvertibleIssuance, OcfConvertibleTransfer, OcfEquityCompensationIssuance, @@ -12,6 +13,15 @@ import type { OcfWarrantIssuance, OcfWarrantTransfer, } from '../../dist'; +import { toNonEmptyArray, toNonEmptyStringArray } from '../../dist/utils/typeConversions'; + +const parsedStrings: NonEmptyArray = toNonEmptyStringArray(['one'], 'items'); +const parsedGenericStrings: NonEmptyArray = toNonEmptyArray(['one'], 'items', (value) => { + if (typeof value !== 'string') throw new Error('Expected string'); + return value; +}); +// @ts-expect-error built generic non-empty conversion requires an explicit element parser +toNonEmptyArray(['one'], 'items'); const stockPlanEmptyStockClassIds: Pick = { // @ts-expect-error stock_class_ids requires at least one item @@ -74,3 +84,5 @@ void warrantTransferEmptyResults; void convertibleTransferEmptyResults; void equityCompensationTransferEmptyResults; void stockConsolidationEmptySecurityIds; +void parsedStrings; +void parsedGenericStrings; diff --git a/test/schemaAlignment/schemaConformance.test.ts b/test/schemaAlignment/schemaConformance.test.ts index a66156b1..2daa5903 100644 --- a/test/schemaAlignment/schemaConformance.test.ts +++ b/test/schemaAlignment/schemaConformance.test.ts @@ -16,6 +16,7 @@ import { inventoryCanonicalOcfObjects, inventoryPinnedOcfNonEmptyArrays, inventoryPinnedOcfObjectProperties, + inventoryPinnedOcfUniqueArrays, inventoryReachableObjectSchemas, normalizeFingerprintText, resolveJsonPointer, @@ -28,6 +29,7 @@ import { EXPECTED_SEMANTIC_REFINEMENTS, OCF_CONDITIONAL_COVERAGE, PINNED_CANONICAL_NON_EMPTY_ARRAYS, + PINNED_CANONICAL_UNIQUE_ARRAYS, PINNED_REACHABLE_SCHEMA_FINGERPRINT, RETIRED_PLAN_SECURITY_SCHEMA_PAIRS, } from './schemaConformanceRegistry'; @@ -468,6 +470,17 @@ describe('schema-driven OCF conformance guardrail', () => { expect(compareCanonicalNonEmptyArrays(canonicalInventory, sdkInventory, pinnedInventory)).toEqual([]); }); + it('pins every canonical top-level uniqueItems constraint for runtime boundary coverage', () => { + const canonicalDiscriminators = new Set( + inventoryCanonicalOcfObjects(REPO_ROOT).map((entry) => entry.discriminator) + ); + const pinnedInventory = inventoryPinnedOcfUniqueArrays(SCHEMA_ROOT).filter((entry) => + canonicalDiscriminators.has(entry.discriminator) + ); + + expect(pinnedInventory).toEqual(PINNED_CANONICAL_UNIQUE_ARRAYS); + }); + it.each(['anyOf', 'oneOf'] as const)( 'retains only top-level minItems constraints guaranteed across every %s branch', (keyword) => { diff --git a/test/schemaAlignment/schemaConformanceHarness.ts b/test/schemaAlignment/schemaConformanceHarness.ts index d3c194a0..4e8a870e 100644 --- a/test/schemaAlignment/schemaConformanceHarness.ts +++ b/test/schemaAlignment/schemaConformanceHarness.ts @@ -53,6 +53,10 @@ export interface PinnedOcfNonEmptyArrayInventoryEntry extends NonEmptyArrayPrope schemaPath: string; } +export interface PinnedOcfUniqueArrayInventoryEntry extends NonEmptyArrayPropertyInventoryEntry { + schemaPath: string; +} + export interface NonEmptyArrayParityProblem { discriminator: string; kind: 'duplicate-schema' | 'schema-only' | 'sdk-only' | 'unsupported-min-items'; @@ -465,6 +469,53 @@ function collectUnconditionalTopLevelNonEmptyArrays(schema: unknown, properties: } } +function guaranteedUniqueItems(schema: unknown): boolean { + if (!isJsonObject(schema)) return false; + if (schema.uniqueItems === true) return true; + + const { allOf } = schema; + if (Array.isArray(allOf) && allOf.some((branch) => guaranteedUniqueItems(branch))) return true; + + for (const keyword of ['anyOf', 'oneOf'] as const) { + const branches = schema[keyword]; + if (Array.isArray(branches) && branches.length > 0 && branches.every((branch) => guaranteedUniqueItems(branch))) { + return true; + } + } + return false; +} + +function collectUnconditionalTopLevelUniqueArrays(schema: unknown, properties: Set): void { + if (!isJsonObject(schema)) return; + + if (isJsonObject(schema.properties)) { + for (const [property, propertySchema] of Object.entries(schema.properties)) { + if (guaranteedUniqueItems(propertySchema)) properties.add(property); + } + } + + const { allOf } = schema; + if (Array.isArray(allOf)) { + allOf.forEach((branch) => collectUnconditionalTopLevelUniqueArrays(branch, properties)); + } + + for (const keyword of ['anyOf', 'oneOf'] as const) { + const branches = schema[keyword]; + if (!Array.isArray(branches) || branches.length === 0) continue; + + const branchProperties = branches.map((branch) => { + const collected = new Set(); + collectUnconditionalTopLevelUniqueArrays(branch, collected); + return collected; + }); + const firstBranch = branchProperties[0]; + if (!firstBranch) continue; + for (const property of firstBranch) { + if (branchProperties.slice(1).every((branch) => branch.has(property))) properties.add(property); + } + } +} + const MAX_SCHEMA_COMPOSITION_DEPTH = 100; type DiscriminatorConstraint = Set | undefined; @@ -637,6 +688,33 @@ export function inventoryPinnedOcfNonEmptyArrays(schemaRoot: string): PinnedOcfN ); } +/** Inventory unconditional top-level array properties whose pinned schema requires unique items. */ +export function inventoryPinnedOcfUniqueArrays(schemaRoot: string): PinnedOcfUniqueArrayInventoryEntry[] { + const objectRoot = path.join(schemaRoot, 'objects'); + return listSchemaFiles(objectRoot) + .flatMap((schemaPath) => { + const dereferenced = dereferenceValue( + readSchemaFile(schemaPath), + schemaPath, + schemaRoot, + new Set([`${schemaPath}#`]) + ); + assertJsonObject(dereferenced, `dereferenced ${schemaPath}`); + const properties = new Set(); + collectUnconditionalTopLevelUniqueArrays(dereferenced, properties); + const relativeSchemaPath = `schema/objects/${normalizeSlashes(path.relative(objectRoot, schemaPath))}`; + return getObjectSchemaDiscriminators(dereferenced, schemaPath).flatMap((discriminator) => + [...properties] + .sort(compareCodeUnits) + .map((property) => ({ discriminator, property, schemaPath: relativeSchemaPath })) + ); + }) + .sort( + (left, right) => + compareCodeUnits(left.discriminator, right.discriminator) || compareCodeUnits(left.property, right.property) + ); +} + function nonEmptyArrayPropertyKey(discriminator: string, property: string): string { return `${discriminator}:${property}`; } diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index d782a96b..5bc10920 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -3,6 +3,7 @@ import type { ConditionalCoverageRegistration, CoverageReference, PinnedOcfNonEmptyArrayInventoryEntry, + PinnedOcfUniqueArrayInventoryEntry, SemanticRefinement, } from './schemaConformanceHarness'; @@ -146,6 +147,35 @@ export const PINNED_CANONICAL_NON_EMPTY_ARRAYS: PinnedOcfNonEmptyArrayInventoryE }, ]; +/** Exact pinned canonical top-level arrays whose schema requires unique items. */ +export const PINNED_CANONICAL_UNIQUE_ARRAYS: PinnedOcfUniqueArrayInventoryEntry[] = [ + { + discriminator: 'TX_CONVERTIBLE_TRANSFER', + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/ConvertibleTransfer.schema.json', + }, + { + discriminator: 'TX_EQUITY_COMPENSATION_TRANSFER', + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/EquityCompensationTransfer.schema.json', + }, + { + discriminator: 'TX_STOCK_CONSOLIDATION', + property: 'security_ids', + schemaPath: 'schema/objects/transactions/consolidation/StockConsolidation.schema.json', + }, + { + discriminator: 'TX_STOCK_TRANSFER', + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/StockTransfer.schema.json', + }, + { + discriminator: 'TX_WARRANT_TRANSFER', + property: 'resulting_security_ids', + schemaPath: 'schema/objects/transactions/transfer/WarrantTransfer.schema.json', + }, +]; + const coreRuntime: CoverageReference = { file: 'test/schemaAlignment/coreConditionalShapes.test.ts', kind: 'runtime', diff --git a/test/types/nonEmptyArrays.types.ts b/test/types/nonEmptyArrays.types.ts index 918c11f1..8a2353e2 100644 --- a/test/types/nonEmptyArrays.types.ts +++ b/test/types/nonEmptyArrays.types.ts @@ -1,4 +1,5 @@ import type { + NonEmptyArray, OcfConvertibleIssuance, OcfConvertibleTransfer, OcfEquityCompensationIssuance, @@ -12,6 +13,15 @@ import type { OcfWarrantIssuance, OcfWarrantTransfer, } from '../../src/types/native'; +import { toNonEmptyArray, toNonEmptyStringArray } from '../../src/utils/typeConversions'; + +const parsedStrings: NonEmptyArray = toNonEmptyStringArray(['one'], 'items'); +const parsedGenericStrings: NonEmptyArray = toNonEmptyArray(['one'], 'items', (value) => { + if (typeof value !== 'string') throw new Error('Expected string'); + return value; +}); +// @ts-expect-error generic non-empty conversion requires an explicit element parser +toNonEmptyArray(['one'], 'items'); const stockPlanEmptyStockClassIds: Pick = { // @ts-expect-error stock_class_ids requires at least one item @@ -74,3 +84,5 @@ void warrantTransferEmptyResults; void convertibleTransferEmptyResults; void equityCompensationTransferEmptyResults; void stockConsolidationEmptySecurityIds; +void parsedStrings; +void parsedGenericStrings; diff --git a/test/utils/arrayCardinality.test.ts b/test/utils/arrayCardinality.test.ts new file mode 100644 index 00000000..128bb99f --- /dev/null +++ b/test/utils/arrayCardinality.test.ts @@ -0,0 +1,151 @@ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { parseArraySnapshot, parseStringArrayItem } from '../../src/utils/arrayCardinality'; + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected array validation to fail'); +} + +describe('parseArraySnapshot', () => { + it('returns a fresh dense snapshot and supports exact cardinality', () => { + const input = ['first', 'second']; + const parsed = parseArraySnapshot(input, 'items', { + cardinality: { minimum: 2, maximum: 2 }, + item: parseStringArrayItem, + }); + + expect(parsed).toEqual(input); + expect(parsed).not.toBe(input); + input[0] = 'mutated'; + expect(parsed).toEqual(['first', 'second']); + }); + + it.each([ + { + cardinality: { minimum: 2 }, + expectedContext: { actualItems: 1, minimumItems: 2 }, + expectedMessage: 'at least 2 items', + input: ['one'], + }, + { + cardinality: { maximum: 1 }, + expectedContext: { actualItems: 2, maximumItems: 1 }, + expectedMessage: 'at most 1 item', + input: ['one', 'two'], + }, + ])('rejects array cardinality outside $cardinality', ({ cardinality, expectedContext, expectedMessage, input }) => { + const error = captureValidationError(() => + parseArraySnapshot(input, 'items', { cardinality, item: parseStringArrayItem }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'items', + }); + expect(error.message).toContain(expectedMessage); + expect(error.context).toMatchObject(expectedContext); + }); + + it('reports an exact item path for invalid element types', () => { + const error = captureValidationError(() => + parseArraySnapshot(['valid', 42], 'transfer.resulting_security_ids', { item: parseStringArrayItem }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + fieldPath: 'transfer.resulting_security_ids.1', + receivedValue: 42, + }); + }); + + it('reports the duplicate item and its first index', () => { + const error = captureValidationError(() => + parseArraySnapshot(['first', 'duplicate', 'duplicate'], 'transfer.resulting_security_ids', { + item: parseStringArrayItem, + uniqueness: { key: (value) => value }, + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'transfer.resulting_security_ids.2', + receivedValue: 'duplicate', + }); + expect(error.context).toMatchObject({ duplicateIndex: 2, duplicateOfIndex: 1 }); + }); + + it('rejects a sparse array at its first missing index', () => { + const error = captureValidationError(() => + parseArraySnapshot(new Array(1), 'transfer.resulting_security_ids', { item: parseStringArrayItem }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'transfer.resulting_security_ids.0', + }); + }); + + it('rejects an accessor element without invoking its getter', () => { + let getterReads = 0; + const input: string[] = []; + Object.defineProperty(input, '0', { + configurable: true, + enumerable: true, + get() { + getterReads += 1; + return 'secret'; + }, + }); + input.length = 1; + + const error = captureValidationError(() => + parseArraySnapshot(input, 'transfer.resulting_security_ids', { item: parseStringArrayItem }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'transfer.resulting_security_ids.0', + receivedValue: 'accessor property', + }); + expect(getterReads).toBe(0); + }); + + it('rejects a Proxy without invoking any traps', () => { + let trapCalls = 0; + const input = new Proxy(['secret'], { + get(target, property, receiver) { + trapCalls += 1; + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + trapCalls += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + trapCalls += 1; + return Reflect.getPrototypeOf(target); + }, + ownKeys(target) { + trapCalls += 1; + return Reflect.ownKeys(target); + }, + }); + + const error = captureValidationError(() => + parseArraySnapshot(input, 'transfer.resulting_security_ids', { item: parseStringArrayItem }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'transfer.resulting_security_ids', + receivedValue: 'JavaScript Proxy', + }); + expect(trapCalls).toBe(0); + }); +}); diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 9deaf2a3..880f6163 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -11,6 +11,7 @@ import { parseDamlMap, quantityTransferToNative, toNonEmptyArray, + toNonEmptyStringArray, } from '../../src/utils/typeConversions'; describe('normalizeNumericString', () => { @@ -128,18 +129,24 @@ describe('ensureArray', () => { describe('non-empty array helpers', () => { test('returns a non-empty tuple without changing its values', () => { - expect(toNonEmptyArray(['first', 'second'], 'items')).toEqual(['first', 'second']); + expect(toNonEmptyStringArray(['first', 'second'], 'items')).toEqual(['first', 'second']); }); test('uses array length for cardinality even when the element type includes undefined', () => { const values: Array = [undefined]; - expect(toNonEmptyArray(values, 'items')).toEqual([undefined]); - expect(nonEmptyArrayOrUndefined(values, 'items')).toEqual([undefined]); + const parseOptionalString = (value: unknown): string | undefined => { + if (value === undefined || typeof value === 'string') return value; + throw new Error('Expected string or undefined'); + }; + expect(toNonEmptyArray(values, 'items', parseOptionalString)).toEqual([undefined]); + expect(nonEmptyArrayOrUndefined(values, 'items', parseOptionalString)).toEqual([undefined]); }); test('rejects an empty required array with field context', () => { - expect(() => toNonEmptyArray([], 'transfer.resulting_security_ids')).toThrow(OcpValidationError); - expect(() => toNonEmptyArray([], 'transfer.resulting_security_ids')).toThrow('transfer.resulting_security_ids'); + expect(() => toNonEmptyStringArray([], 'transfer.resulting_security_ids')).toThrow(OcpValidationError); + expect(() => toNonEmptyStringArray([], 'transfer.resulting_security_ids')).toThrow( + 'transfer.resulting_security_ids' + ); }); test.each([ @@ -147,15 +154,16 @@ describe('non-empty array helpers', () => { ['null', null], ['undefined', undefined], ])('rejects a malformed %s container with a typed field error', (_name, values) => { - const invokeRequired = () => toNonEmptyArray(values as never, 'transfer.resulting_security_ids'); - const invokeOptional = () => nonEmptyArrayOrUndefined(values as never, 'issuance.vestings'); + const invokeRequired = () => toNonEmptyStringArray(values, 'transfer.resulting_security_ids'); + const invokeOptional = () => nonEmptyArrayOrUndefined(values, 'issuance.vestings', (value) => value); expect(invokeRequired).toThrow(OcpValidationError); try { invokeRequired(); } catch (error) { expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, + code: + values === null || values === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, fieldPath: 'transfer.resulting_security_ids', expectedType: 'array', receivedValue: values, @@ -167,7 +175,8 @@ describe('non-empty array helpers', () => { invokeOptional(); } catch (error) { expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, + code: + values === null || values === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, fieldPath: 'issuance.vestings', expectedType: 'array', receivedValue: values, @@ -176,8 +185,8 @@ describe('non-empty array helpers', () => { }); test('converts optional arrays to a non-empty tuple or undefined', () => { - expect(nonEmptyArrayOrUndefined([], 'items')).toBeUndefined(); - expect(nonEmptyArrayOrUndefined(['only'], 'items')).toEqual(['only']); + expect(nonEmptyArrayOrUndefined([], 'items', (value) => value)).toBeUndefined(); + expect(nonEmptyArrayOrUndefined(['only'], 'items', (value) => value)).toEqual(['only']); }); }); From 0ea8345e02b6de572aa333aed9c4c9f79a8aedff Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 03:48:25 -0400 Subject: [PATCH 09/10] refactor: centralize dense array validation --- .../OpenCapTable/shared/ocfValues.ts | 109 +------------ src/utils/arrayCardinality.ts | 138 +---------------- src/utils/denseArray.ts | 146 ++++++++++++++++++ test/utils/arrayCardinality.test.ts | 46 ++++++ 4 files changed, 201 insertions(+), 238 deletions(-) create mode 100644 src/utils/denseArray.ts diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 64bc22db..8e63efe5 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -3,6 +3,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { boundedDiagnosticPath, diagnosticPropertyPath } from '../../../errors/diagnosticValue'; import type { Monetary } from '../../../types/native'; import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; +import { snapshotDenseArrayValues } from '../../../utils/denseArray'; import { isRecord } from '../../../utils/typeConversions'; interface DecimalRange { @@ -442,115 +443,9 @@ export function requireMonetary(value: unknown, fieldPath: string): Monetary { }; } -function arrayPropertyPath(fieldPath: string, key: string | symbol): string { - return diagnosticPropertyPath(fieldPath, key); -} - -function arrayShapeError( - fieldPath: string, - message: string, - expectedType: string, - receivedValue: unknown -): OcpValidationError { - return new OcpValidationError(fieldPath, message, { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType, - receivedValue, - }); -} - /** Require an ordinary, lossless JSON array and attribute malformed structure to its exact path. */ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] { - if (value === null || value === undefined) throw requiredMissing(fieldPath, 'array', value); - assertNotRuntimeProxy(value, fieldPath, 'ordinary JSON array'); - if (!Array.isArray(value)) throw invalidType(fieldPath, 'array', value); - - if (Object.getPrototypeOf(value) !== Array.prototype) { - throw arrayShapeError( - fieldPath, - `${fieldPath} must use the canonical Array prototype`, - 'ordinary JSON array', - 'custom array prototype' - ); - } - - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) { - throw arrayShapeError( - `${fieldPath}.length`, - `${fieldPath}.length must be an own data property`, - 'own array length data property', - 'missing or accessor length property' - ); - } - const length = lengthDescriptor.value as number; - const indices = new Set(); - - for (const key of Object.getOwnPropertyNames(value)) { - if (key === 'length') continue; - const propertyPath = arrayPropertyPath(fieldPath, key); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor)) { - throw arrayShapeError( - propertyPath, - `${propertyPath} must be an own data property`, - 'own array item data property', - 'accessor property' - ); - } - if (descriptor.enumerable !== true) { - throw arrayShapeError( - propertyPath, - `${propertyPath} must be enumerable`, - 'enumerable own array item data property', - 'non-enumerable property' - ); - } - - const index = Number(key); - if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { - throw arrayShapeError( - propertyPath, - `${propertyPath} is not a canonical array index`, - 'array index or length only', - descriptor.value - ); - } - indices.add(index); - } - - const symbol = Object.getOwnPropertySymbols(value)[0]; - if (symbol !== undefined) { - const propertyPath = arrayPropertyPath(fieldPath, symbol); - throw arrayShapeError( - propertyPath, - `${propertyPath} is not supported on an OCF array`, - 'array without symbol properties', - symbol - ); - } - - if (indices.size !== length) { - let missingIndex = 0; - while (indices.has(missingIndex)) { - missingIndex += 1; - } - throw requiredMissing(arrayPropertyPath(fieldPath, String(missingIndex)), 'array item', undefined); - } - - for (const key in value) { - if (!Object.prototype.hasOwnProperty.call(value, key)) { - const propertyPath = arrayPropertyPath(fieldPath, key); - throw arrayShapeError( - propertyPath, - `${propertyPath} is inherited rather than own`, - 'own array index', - 'inherited property' - ); - } - } - - return value; + return snapshotDenseArrayValues(value, fieldPath); } /** Require a dense array whose values are strings, preserving schema-valid empty strings. */ diff --git a/src/utils/arrayCardinality.ts b/src/utils/arrayCardinality.ts index 2857a72c..9e74f243 100644 --- a/src/utils/arrayCardinality.ts +++ b/src/utils/arrayCardinality.ts @@ -1,6 +1,6 @@ -import { types as nodeUtilTypes } from 'node:util'; import { OcpErrorCodes, OcpValidationError } from '../errors'; import { diagnosticPropertyPath } from '../errors/diagnosticValue'; +import { snapshotDenseArrayValues } from './denseArray'; export interface ArrayItemContext { readonly fieldPath: string; @@ -25,19 +25,6 @@ export interface ParseArraySnapshotOptions { readonly uniqueness?: ArrayUniqueness; } -function invalidArrayShape( - fieldPath: string, - message: string, - expectedType: string, - receivedValue: unknown -): OcpValidationError { - return new OcpValidationError(fieldPath, message, { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType, - receivedValue, - }); -} - function validateCardinalityConfiguration(cardinality: ArrayCardinality): void { const { maximum, minimum } = cardinality; if (minimum !== undefined && (!Number.isSafeInteger(minimum) || minimum < 0)) { @@ -60,116 +47,8 @@ function validateCardinalityConfiguration(cardinality: ArrayCardinality): void { export function parseArraySnapshot(value: unknown, fieldPath: string, options: ParseArraySnapshotOptions): T[] { const cardinality = options.cardinality ?? {}; validateCardinalityConfiguration(cardinality); - - if (nodeUtilTypes.isProxy(value)) { - throw invalidArrayShape( - fieldPath, - `${fieldPath} cannot be a JavaScript Proxy`, - 'ordinary JSON array', - 'JavaScript Proxy' - ); - } - if (value === null || value === undefined) { - throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'array', - receivedValue: value, - }); - } - if (!Array.isArray(value)) { - throw new OcpValidationError(fieldPath, `${fieldPath} must be an array`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'array', - receivedValue: value, - }); - } - if (Object.getPrototypeOf(value) !== Array.prototype) { - throw invalidArrayShape( - fieldPath, - `${fieldPath} must use the canonical Array prototype`, - 'ordinary JSON array', - 'custom array prototype' - ); - } - - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) { - throw invalidArrayShape( - diagnosticPropertyPath(fieldPath, 'length'), - `${fieldPath}.length must be an own data property`, - 'own array length data property', - 'missing or accessor length property' - ); - } - const length = lengthDescriptor.value as number; - const itemDescriptors = new Map(); - - for (const key of Object.getOwnPropertyNames(value)) { - if (key === 'length') continue; - const propertyPath = diagnosticPropertyPath(fieldPath, key); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor)) { - throw invalidArrayShape( - propertyPath, - `${propertyPath} must be an own data property`, - 'own array item data property', - 'accessor property' - ); - } - if (descriptor.enumerable !== true) { - throw invalidArrayShape( - propertyPath, - `${propertyPath} must be enumerable`, - 'enumerable own array item data property', - 'non-enumerable property' - ); - } - - const index = Number(key); - if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { - throw invalidArrayShape( - propertyPath, - `${propertyPath} is not a canonical array index`, - 'array index or length only', - descriptor.value - ); - } - itemDescriptors.set(index, descriptor); - } - - const symbol = Object.getOwnPropertySymbols(value)[0]; - if (symbol !== undefined) { - const propertyPath = diagnosticPropertyPath(fieldPath, symbol); - throw invalidArrayShape( - propertyPath, - `${propertyPath} is not supported on an OCF array`, - 'array without symbol properties', - symbol - ); - } - - if (itemDescriptors.size !== length) { - let missingIndex = 0; - while (itemDescriptors.has(missingIndex)) missingIndex += 1; - const itemPath = diagnosticPropertyPath(fieldPath, String(missingIndex)); - throw new OcpValidationError(itemPath, `${itemPath} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'own array item data property', - receivedValue: undefined, - }); - } - - for (const key in value) { - if (!Object.prototype.hasOwnProperty.call(value, key)) { - const propertyPath = diagnosticPropertyPath(fieldPath, key); - throw invalidArrayShape( - propertyPath, - `${propertyPath} is inherited rather than own`, - 'own array index', - 'inherited property' - ); - } - } + const values = snapshotDenseArrayValues(value, fieldPath); + const { length } = values; const { maximum, minimum } = cardinality; if (minimum !== undefined && length < minimum) { @@ -179,7 +58,7 @@ export function parseArraySnapshot(value: unknown, fieldPath: string, options { code: OcpErrorCodes.OUT_OF_RANGE, expectedType: `array with at least ${minimum} item${minimum === 1 ? '' : 's'}`, - receivedValue: value, + receivedValue: values, context: { actualItems: length, minimumItems: minimum }, } ); @@ -191,7 +70,7 @@ export function parseArraySnapshot(value: unknown, fieldPath: string, options { code: OcpErrorCodes.OUT_OF_RANGE, expectedType: `array with at most ${maximum} item${maximum === 1 ? '' : 's'}`, - receivedValue: value, + receivedValue: values, context: { actualItems: length, maximumItems: maximum }, } ); @@ -200,12 +79,9 @@ export function parseArraySnapshot(value: unknown, fieldPath: string, options const parsed: T[] = []; const firstIndexByKey = new Map(); for (let index = 0; index < length; index += 1) { - const descriptor = itemDescriptors.get(index); - if (descriptor === undefined || !('value' in descriptor)) { - throw new Error(`Dense array descriptor disappeared at index ${index}`); - } const itemPath = diagnosticPropertyPath(fieldPath, String(index)); - const item = options.item(descriptor.value, { fieldPath: itemPath, index }); + const { [index]: itemValue } = values; + const item = options.item(itemValue, { fieldPath: itemPath, index }); if (options.uniqueness !== undefined) { const key = options.uniqueness.key(item); const firstIndex = firstIndexByKey.get(key); diff --git a/src/utils/denseArray.ts b/src/utils/denseArray.ts new file mode 100644 index 00000000..e95a1d56 --- /dev/null +++ b/src/utils/denseArray.ts @@ -0,0 +1,146 @@ +import { types as nodeUtilTypes } from 'node:util'; +import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { diagnosticPropertyPath } from '../errors/diagnosticValue'; + +function invalidArrayShape( + fieldPath: string, + message: string, + expectedType: string, + receivedValue: unknown +): OcpValidationError { + return new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue, + }); +} + +/** + * Validate and snapshot one untrusted JSON array without invoking Proxy traps + * or element accessors. + * + * This is the single structural boundary shared by generic array consumers and + * schema-cardinality parsing. The returned value is always a fresh, dense, + * ordinary Array populated from inspected own data descriptors. + */ +export function snapshotDenseArrayValues(value: unknown, fieldPath: string): unknown[] { + if (nodeUtilTypes.isProxy(value)) { + throw invalidArrayShape( + fieldPath, + `${fieldPath} cannot be a JavaScript Proxy`, + 'ordinary JSON array', + 'JavaScript Proxy' + ); + } + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'array', + receivedValue: value, + }); + } + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be an array`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: value, + }); + } + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw invalidArrayShape( + fieldPath, + `${fieldPath} must use the canonical Array prototype`, + 'ordinary JSON array', + 'custom array prototype' + ); + } + + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) { + throw invalidArrayShape( + diagnosticPropertyPath(fieldPath, 'length'), + `${fieldPath}.length must be an own data property`, + 'own array length data property', + 'missing or accessor length property' + ); + } + const length = lengthDescriptor.value as number; + const itemDescriptors = new Map(); + + for (const key of Object.getOwnPropertyNames(value)) { + if (key === 'length') continue; + const propertyPath = diagnosticPropertyPath(fieldPath, key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw invalidArrayShape( + propertyPath, + `${propertyPath} must be an own data property`, + 'own array item data property', + 'accessor property' + ); + } + if (descriptor.enumerable !== true) { + throw invalidArrayShape( + propertyPath, + `${propertyPath} must be enumerable`, + 'enumerable own array item data property', + 'non-enumerable property' + ); + } + + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { + throw invalidArrayShape( + propertyPath, + `${propertyPath} is not a canonical array index`, + 'array index or length only', + descriptor.value + ); + } + itemDescriptors.set(index, descriptor); + } + + const symbol = Object.getOwnPropertySymbols(value)[0]; + if (symbol !== undefined) { + const propertyPath = diagnosticPropertyPath(fieldPath, symbol); + throw invalidArrayShape( + propertyPath, + `${propertyPath} is not supported on an OCF array`, + 'array without symbol properties', + symbol + ); + } + + if (itemDescriptors.size !== length) { + let missingIndex = 0; + while (itemDescriptors.has(missingIndex)) missingIndex += 1; + const itemPath = diagnosticPropertyPath(fieldPath, String(missingIndex)); + throw new OcpValidationError(itemPath, `${itemPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'own array item data property', + receivedValue: undefined, + }); + } + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + const propertyPath = diagnosticPropertyPath(fieldPath, key); + throw invalidArrayShape( + propertyPath, + `${propertyPath} is inherited rather than own`, + 'own array index', + 'inherited property' + ); + } + } + + const snapshot = new Array(length); + for (let index = 0; index < length; index += 1) { + const descriptor = itemDescriptors.get(index); + if (descriptor === undefined || !('value' in descriptor)) { + throw new Error(`Dense array descriptor disappeared at index ${index}`); + } + snapshot[index] = descriptor.value; + } + return snapshot; +} diff --git a/test/utils/arrayCardinality.test.ts b/test/utils/arrayCardinality.test.ts index 128bb99f..e79a665e 100644 --- a/test/utils/arrayCardinality.test.ts +++ b/test/utils/arrayCardinality.test.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { requireDenseArray } from '../../src/functions/OpenCapTable/shared/ocfValues'; import { parseArraySnapshot, parseStringArrayItem } from '../../src/utils/arrayCardinality'; function captureValidationError(action: () => unknown): OcpValidationError { @@ -25,6 +26,51 @@ describe('parseArraySnapshot', () => { expect(parsed).toEqual(['first', 'second']); }); + it('shares one fresh snapshot boundary with generic dense-array consumers', () => { + const input = ['first', 'second']; + + const genericSnapshot = requireDenseArray(input, 'items'); + const cardinalitySnapshot = parseArraySnapshot(input, 'items', { item: parseStringArrayItem }); + + expect(genericSnapshot).toEqual(input); + expect(genericSnapshot).not.toBe(input); + expect(cardinalitySnapshot).toEqual(genericSnapshot); + input[0] = 'mutated'; + expect(genericSnapshot).toEqual(['first', 'second']); + expect(cardinalitySnapshot).toEqual(['first', 'second']); + }); + + it('attributes accessor items identically through both array consumers without invoking the getter', () => { + let getterReads = 0; + const input: string[] = []; + Object.defineProperty(input, '0', { + configurable: true, + enumerable: true, + get() { + getterReads += 1; + return 'secret'; + }, + }); + input.length = 1; + + const genericError = captureValidationError(() => requireDenseArray(input, 'items')); + const cardinalityError = captureValidationError(() => + parseArraySnapshot(input, 'items', { item: parseStringArrayItem }) + ); + + expect(genericError).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'items.0', + receivedValue: 'accessor property', + }); + expect(cardinalityError).toMatchObject({ + code: genericError.code, + fieldPath: genericError.fieldPath, + receivedValue: genericError.receivedValue, + }); + expect(getterReads).toBe(0); + }); + it.each([ { cardinality: { minimum: 2 }, From 06a169b9450f6e2c79a5a10b14d0791cc6e8bbad Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:24:20 -0400 Subject: [PATCH 10/10] fix: preserve schema-valid vesting rows --- src/functions/OpenCapTable/shared/vesting.ts | 61 ++++++---- .../warrantIssuance/createWarrantIssuance.ts | 29 ++--- .../vestingCardinalityBoundaries.test.ts | 107 ++++++++++++++++++ .../warrantIssuanceConverters.test.ts | 7 +- .../dateBoundaryInvariants.test.ts | 19 ++-- test/utils/vesting.test.ts | 21 +++- 6 files changed, 191 insertions(+), 53 deletions(-) create mode 100644 test/converters/vestingCardinalityBoundaries.test.ts diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index 932717a9..9254b809 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,5 +1,10 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { dateStringToDAMLTime, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; +import { + dateStringToDAMLTime, + isRecord, + normalizeNumericString, + toNonEmptyArray, +} from '../../../utils/typeConversions'; interface VestingInput { date: string; @@ -11,34 +16,40 @@ interface DamlVesting { amount: string; } -/** Validate every vesting row, then filter zero-value placeholders while retaining original indexes. */ +/** Encode omission as DAML `[]`; validate a present OCF array as schema-non-empty and preserve every row. */ export function filterAndMapVestingsToDaml( - vestings: readonly VestingInput[] | null | undefined, + vestings: readonly VestingInput[] | undefined, basePath: string ): DamlVesting[] { - return (vestings ?? []) - .map((vesting, index) => { - const vestingPath = `${basePath}.${index}`; - if (!isRecord(vesting)) { - throw new OcpValidationError(vestingPath, 'Vesting must be an object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: vesting, - }); - } + if (vestings === undefined) return []; + return toNonEmptyArray(vestings, basePath, (vesting, { fieldPath: vestingPath }) => { + if (!isRecord(vesting)) { + throw new OcpValidationError(vestingPath, 'Vesting must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: vesting, + }); + } - const amountPath = `${vestingPath}.amount`; - const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); - const amount = normalizeNumericString(vesting.amount, amountPath); + const amountPath = `${vestingPath}.amount`; + const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); + const amountValue = vesting.amount; + if (typeof amountValue !== 'string' && typeof amountValue !== 'number') { + throw new OcpValidationError(amountPath, 'Vesting amount must be a decimal string or number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: amountValue, + }); + } + const amount = normalizeNumericString(amountValue, amountPath); - if (Number(amount) < 0) { - throw new OcpValidationError(amountPath, 'Vesting amount must not be negative', { - code: OcpErrorCodes.OUT_OF_RANGE, - receivedValue: vesting.amount, - }); - } + if (Number(amount) < 0) { + throw new OcpValidationError(amountPath, 'Vesting amount must not be negative', { + code: OcpErrorCodes.OUT_OF_RANGE, + receivedValue: amountValue, + }); + } - return { date, amount }; - }) - .filter(({ amount }) => Number(amount) !== 0); + return { date, amount }; + }); } diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 8a3858f9..5ab81bc3 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -407,7 +407,21 @@ export function warrantIssuanceDataToDaml( const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers'); const damlTriggers = triggers.map(triggerToDaml); assertUniqueConversionTriggerIds(damlTriggers, 'warrantIssuance.exercise_triggers', OcpErrorCodes.INVALID_FORMAT); - const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings'); + const vestings = + issuance.vestings === undefined + ? [] + : filterAndMapVestingsToDaml( + optionalArray(issuance.vestings, 'warrantIssuance.vestings').map((value, index) => { + const source = `warrantIssuance.vestings.${index}`; + const vesting = requireRecord(value, source); + assertExactObjectFields(vesting, VESTING_FIELDS, source); + return { + date: vesting.date as string, + amount: vesting.amount as string, + }; + }), + 'warrantIssuance.vestings' + ); return { id: requireString(issuance.id, 'warrantIssuance.id'), @@ -438,18 +452,7 @@ export function warrantIssuanceDataToDaml( 'warrantIssuance.warrant_expiration_date' ), vesting_terms_id: optionalTextToDaml(issuance.vesting_terms_id, 'warrantIssuance.vesting_terms_id'), - vestings: filterAndMapVestingsToDaml( - vestings.map((value, index) => { - const source = `warrantIssuance.vestings.${index}`; - const vesting = requireRecord(value, source); - assertExactObjectFields(vesting, VESTING_FIELDS, source); - return { - date: vesting.date as string, - amount: vesting.amount as string, - }; - }), - 'warrantIssuance.vestings' - ), + vestings, comments: commentsToDaml(issuance.comments, 'warrantIssuance.comments'), }; } diff --git a/test/converters/vestingCardinalityBoundaries.test.ts b/test/converters/vestingCardinalityBoundaries.test.ts new file mode 100644 index 00000000..55e0e605 --- /dev/null +++ b/test/converters/vestingCardinalityBoundaries.test.ts @@ -0,0 +1,107 @@ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; + +interface EncodedIssuance { + readonly vestings: ReadonlyArray<{ readonly amount: string; readonly date: string }>; +} + +interface VestingWriterCase { + readonly base: Readonly>; + readonly label: string; + readonly path: string; + readonly write: (input: Record) => EncodedIssuance; +} + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected vesting cardinality validation to fail'); +} + +const cases: readonly VestingWriterCase[] = [ + { + label: 'StockIssuance', + path: 'stockIssuance.vestings', + base: { + object_type: 'TX_STOCK_ISSUANCE', + id: 'stock-issuance-1', + date: '2026-01-01', + security_id: 'stock-security-1', + custom_id: 'CS-1', + stakeholder_id: 'stakeholder-1', + stock_class_id: 'stock-class-1', + share_price: { amount: '1', currency: 'USD' }, + quantity: '100', + security_law_exemptions: [], + stock_legend_ids: [], + }, + write: (input) => stockIssuanceDataToDaml(input as never), + }, + { + label: 'EquityCompensationIssuance', + path: 'equityCompensationIssuance.vestings', + base: { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', + id: 'equity-issuance-1', + date: '2026-01-01', + security_id: 'equity-security-1', + custom_id: 'EQ-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'RSU', + quantity: '100', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + }, + write: (input) => equityCompensationIssuanceDataToDaml(input as never) as unknown as EncodedIssuance, + }, + { + label: 'WarrantIssuance', + path: 'warrantIssuance.vestings', + base: { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-issuance-1', + date: '2026-01-01', + security_id: 'warrant-security-1', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [], + security_law_exemptions: [], + }, + write: (input) => warrantIssuanceDataToDaml(input as never), + }, +]; + +describe('optional schema-non-empty vesting boundaries', () => { + it.each(cases)( + '$label rejects explicitly present [] in both schema and writer runtime boundaries', + ({ base, path, write }) => { + expect(parseOcfObject(base)).toBeDefined(); + expect(() => parseOcfObject({ ...base, vestings: [] })).toThrow('must NOT have fewer than 1 items'); + + expect(captureValidationError(() => write({ ...base, vestings: [] }))).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: path, + receivedValue: [], + }); + } + ); + + it.each(cases)('$label maps OCF omission to the DAML [] representation', ({ base, write }) => { + expect(write({ ...base }).vestings).toEqual([]); + }); + + it.each(cases)('$label preserves a schema-valid zero-amount vesting row', ({ base, write }) => { + const input = { ...base, vestings: [{ date: '2026-02-01', amount: '0' }] }; + expect(parseOcfObject(input)).toMatchObject({ vestings: [{ date: '2026-02-01', amount: '0' }] }); + expect(write(input).vestings).toEqual([{ date: '2026-02-01T00:00:00.000Z', amount: '0' }]); + }); +}); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 98f65447..c1d11830 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -798,7 +798,7 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - it('validates zero-amount vesting dates before filtering and preserves original indexes', () => { + it('validates zero-amount vesting dates and preserves zero rows with their original indexes', () => { expectInvalidWarrantDate( () => warrantIssuanceDataToDaml({ @@ -820,7 +820,10 @@ describe('WarrantIssuance round-trip equivalence', () => { { date: '2024-02-01', amount: '1' }, ], }); - expect(encoded.vestings).toEqual([{ date: '2024-02-01T00:00:00.000Z', amount: '1' }]); + expect(encoded.vestings).toEqual([ + { date: '2024-01-01T00:00:00.000Z', amount: '0' }, + { date: '2024-02-01T00:00:00.000Z', amount: '1' }, + ]); }); it('rejects a negative vesting amount instead of silently dropping it', () => { diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index abe49969..d7d02cf8 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -81,7 +81,7 @@ describe('array diagnostic path classification', () => { }); describe('date boundary source invariants', () => { - test('validates shared vesting rows before filtering and routes every writer through that boundary', () => { + test('validates and preserves shared vesting rows behind a non-empty present-array boundary', () => { const helperSource = ts.createSourceFile( VESTING_HELPER_FILE, fs.readFileSync(VESTING_HELPER_FILE, 'utf8'), @@ -91,12 +91,14 @@ describe('date boundary source invariants', () => { ); let dateValidationPosition: number | undefined; let amountValidationPosition: number | undefined; + let cardinalityValidationPosition: number | undefined; let filterPosition: number | undefined; function visitHelper(node: ts.Node): void { if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { if (node.expression.text === 'dateStringToDAMLTime') dateValidationPosition = node.getStart(helperSource); if (node.expression.text === 'normalizeNumericString') amountValidationPosition = node.getStart(helperSource); + if (node.expression.text === 'toNonEmptyArray') cardinalityValidationPosition = node.getStart(helperSource); } if ( ts.isCallExpression(node) && @@ -111,9 +113,8 @@ describe('date boundary source invariants', () => { expect(dateValidationPosition).toBeDefined(); expect(amountValidationPosition).toBeDefined(); - expect(filterPosition).toBeDefined(); - expect(dateValidationPosition).toBeLessThan(filterPosition as number); - expect(amountValidationPosition).toBeLessThan(filterPosition as number); + expect(cardinalityValidationPosition).toBeDefined(); + expect(filterPosition).toBeUndefined(); for (const relativeFile of VESTING_WRITER_FILES) { const file = path.join(SRC_ROOT, 'functions', 'OpenCapTable', relativeFile); @@ -130,16 +131,14 @@ describe('date boundary source invariants', () => { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - node.expression.text === 'equityCompensationIssuancePayloadToDaml' + node.expression.text === 'filterAndMapVestingsToDaml' ) { delegatesVestings = true; } if ( - ts.isPropertyAssignment(node) && - node.name.getText(sourceFile) === 'vestings' && - ts.isCallExpression(node.initializer) && - ts.isIdentifier(node.initializer.expression) && - node.initializer.expression.text === 'filterAndMapVestingsToDaml' + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'equityCompensationIssuancePayloadToDaml' ) { delegatesVestings = true; } diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts index be9809ab..0ad42f88 100644 --- a/test/utils/vesting.test.ts +++ b/test/utils/vesting.test.ts @@ -14,7 +14,18 @@ function captureError(action: () => unknown): OcpValidationError { } describe('shared vesting write boundary', () => { - test('validates and canonicalizes every row before filtering exact zero placeholders', () => { + test('maps omission to DAML [] but rejects an explicitly present empty OCF array', () => { + expect(filterAndMapVestingsToDaml(undefined, PATH)).toEqual([]); + + const error = captureError(() => filterAndMapVestingsToDaml([], PATH)); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: PATH, + receivedValue: [], + }); + }); + + test('validates, canonicalizes, and preserves zero-value vesting rows', () => { expect( filterAndMapVestingsToDaml( [ @@ -24,10 +35,14 @@ describe('shared vesting write boundary', () => { ], PATH ) - ).toEqual([{ date: '2026-03-01T00:00:00.000Z', amount: '10.5' }]); + ).toEqual([ + { date: '2026-01-01T00:00:00.000Z', amount: '0' }, + { date: '2026-02-01T00:00:00.000Z', amount: '-0' }, + { date: '2026-03-01T00:00:00.000Z', amount: '10.5' }, + ]); }); - test.each(['0', '-0'])('rejects a malformed date before filtering placeholder amount %s', (amount) => { + test.each(['0', '-0'])('rejects a malformed date on a preserved zero-amount row %s', (amount) => { const error = captureError(() => filterAndMapVestingsToDaml([{ date: 'not-a-date', amount }], PATH)); expect(error).toMatchObject({