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/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 dbad8dad..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 } 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: d.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 53cee57e..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 } 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: data.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 74ddc7f0..720a2b0d 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -7,9 +7,11 @@ import type { PeriodType, TerminationWindowReason, } from '../../../types/native'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, isRecord, + nonEmptyArrayOrUndefined, normalizeNumericString, nullableDamlTimeToDateString, optionalDamlTimeToDateString, @@ -111,40 +113,43 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? d.vestings.map((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, - } - ); - } + 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), - }; - }) - : 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 f4de95cb..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 } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString, toNonEmptyStringArray } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -46,7 +46,11 @@ 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: toNonEmptyStringArray( + data.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 } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), 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/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/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index c6efebc9..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 } 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: d.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/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/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 2df3b5ba..0b778c0e 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,10 +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 { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlMonetaryToNative, damlTimeToDateString, isRecord, + nonEmptyArrayOrUndefined, normalizeNumericString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; @@ -148,7 +150,6 @@ export function damlStockIssuanceDataToNative( classification: 'invalid_stock_issuance_data_shape', }); } - const anyD = d as unknown as Record; const id = requireStockIssuanceString(anyD, 'id'); const date = requireStockIssuanceString(anyD, 'date'); @@ -157,23 +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 = 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), - }; - }) - : []; + 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'); @@ -207,7 +204,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/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 868501b1..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 } 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: data.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/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 16c84651..93ed5227 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/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 93c6cb92..3ac01404 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -5,6 +5,7 @@ import type { GetByContractIdParams } from '../../../types/common'; import type { ConvertibleConversionRight, Monetary, + NonEmptyArray, OcfWarrantIssuance, QuantitySourceType, StockClassConversionRight, @@ -18,10 +19,12 @@ import { assertUniqueConversionTriggerIds, parseConversionTriggerFields, } from '../../../utils/conversionTriggers'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, + nonEmptyArrayOrUndefined, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; @@ -294,12 +297,10 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined } } -function vestingsFromDaml(value: unknown): [VestingSimple, ...VestingSimple[]] | 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) => { +function vestingsFromDaml(value: unknown): NonEmptyArray | undefined { + 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); @@ -311,8 +312,6 @@ function vestingsFromDaml(value: unknown): [VestingSimple, ...VestingSimple[]] | amount: normalizedAmount, }; }); - if (vestings.length === 0) return undefined; - return vestings as [VestingSimple, ...VestingSimple[]]; } 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..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 } 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: data.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(); // ===== Conversion Functions ===== +function invalidBrandValue(value: unknown): string { + return JSON.stringify({ value: toBoundedDiagnosticValue(value) }); +} + /** - * 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: ${invalidBrandValue(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: ${invalidBrandValue(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: ${invalidBrandValue(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: ${invalidBrandValue(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/src/types/native.ts b/src/types/native.ts index 4b9138a7..3b9f8922 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -865,10 +865,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; @@ -1095,7 +1095,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 */ @@ -1350,7 +1350,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 */ @@ -1398,7 +1398,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 */ @@ -1421,7 +1421,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 */ @@ -1444,7 +1444,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 */ @@ -1832,7 +1832,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 */ @@ -1876,7 +1876,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/arrayCardinality.ts b/src/utils/arrayCardinality.ts new file mode 100644 index 00000000..9e74f243 --- /dev/null +++ b/src/utils/arrayCardinality.ts @@ -0,0 +1,115 @@ +import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { diagnosticPropertyPath } from '../errors/diagnosticValue'; +import { snapshotDenseArrayValues } from './denseArray'; + +export interface ArrayItemContext { + readonly fieldPath: string; + readonly index: number; +} + +export type ArrayItemParser = (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 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); + const values = snapshotDenseArrayValues(value, fieldPath); + const { length } = values; + + 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: values, + 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: values, + context: { actualItems: length, maximumItems: maximum }, + } + ); + } + + const parsed: T[] = []; + const firstIndexByKey = new Map(); + for (let index = 0; index < length; index += 1) { + const itemPath = diagnosticPropertyPath(fieldPath, String(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); + 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/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/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 896be085..d0749039 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -6,7 +6,13 @@ */ 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'; +import { + parseArraySnapshot, + parseStringArrayItem, + type ArrayItemParser, + type ArrayUniqueness, +} from './arrayCardinality'; import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; // Public conversion helpers use stable structural wire shapes. Generated DAML @@ -608,6 +614,62 @@ export function ensureArray(value: T[] | null | undefined): T[] { return []; } +export interface NonEmptyArrayConversionOptions { + readonly maximum?: number; + readonly uniqueness?: ArrayUniqueness; +} + +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 } } + : {}), + }); +} + /** * Filter out empty string entries from a comments array. * Defensively handles null values that may appear at runtime despite TypeScript types. @@ -651,7 +713,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[]; @@ -669,12 +731,19 @@ 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: d.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/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 4c101d8f..c72d07c7 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -611,6 +611,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/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 58e2272a..d912229a 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -12,7 +12,10 @@ */ import { OcpErrorCodes, OcpParseError, OcpValidationError } 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'; @@ -141,7 +144,7 @@ function buildConvertibleNoteInput(interestRate: unknown) { describe('SAFE conversion_timing DAML constructor names', () => { it('emits OcfConvTimingPostMoney for POST_MONEY (not OcfConversionTimingPostMoney)', () => { - const input = { + const input: Parameters[0] = { ...BASE_INPUT, conversion_triggers: [ { @@ -169,7 +172,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }); it('emits OcfConvTimingPreMoney for PRE_MONEY (not OcfConversionTimingPreMoney)', () => { - const input = { + const input: Parameters[0] = { ...BASE_INPUT, conversion_triggers: [ { @@ -197,7 +200,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }); it('emits null for absent conversion_timing', () => { - const input = { + const input: Parameters[0] = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE], }; @@ -236,7 +239,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }); describe('convertible issuance discriminator and required-ID boundaries', () => { - const validInput = { + const validInput: Parameters[0] = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE], }; @@ -603,7 +606,7 @@ describe('convertible issuance runtime-total writer boundary', () => { }); describe('convertible issuance seniority write boundary', () => { - const validInput = { + const validInput: ConvertibleIssuanceInput = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE], }; @@ -1375,7 +1378,7 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru describe('SAFE conversion_timing round-trip', () => { function roundTrip(conversionTiming: 'PRE_MONEY' | 'POST_MONEY' | undefined) { - const input = { + const input: Parameters[0] = { ...BASE_INPUT, conversion_triggers: [ { diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index 06cb0f34..0a18305f 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -236,6 +236,62 @@ 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); + }); }); describe('equity compensation Monetary exactness', () => { 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 de1e7f79..9654cda9 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -24,6 +24,7 @@ import { getStockClassConversionRatioAdjustmentAsOcf } from '../../src/functions import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; 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, @@ -904,6 +905,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.OUT_OF_RANGE, + fieldPath: 'stockConsolidation.security_ids', + receivedValue: [], + } satisfies Partial); + }); + }); + describe('damlStockReissuanceToNative', () => { test('converts basic stock reissuance data', () => { const damlData = { 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/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 167f4101..3cf19c61 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -801,7 +801,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({ @@ -823,7 +823,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', () => { @@ -1104,7 +1107,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }); test('warrant issuance with quantity and quantity_source survives round-trip', () => { - const input = { + const input: Parameters[0] = { ...baseWarrantIssuance, quantity: '1000', quantity_source: 'INSTRUMENT_FIXED' as const, @@ -1146,7 +1149,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }); test('warrant issuance with approval dates, consideration_text, and vestings survives round-trip', () => { - const input = { + const input: Parameters[0] = { ...baseWarrantIssuance, board_approval_date: '2024-06-01', stockholder_approval_date: '2024-06-05', 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 new file mode 100644 index 00000000..95a3676a --- /dev/null +++ b/test/declarations/nonEmptyArrays.types.ts @@ -0,0 +1,88 @@ +import type { + NonEmptyArray, + OcfConvertibleIssuance, + OcfConvertibleTransfer, + OcfEquityCompensationIssuance, + OcfEquityCompensationTransfer, + OcfFinancing, + OcfStockConsolidation, + OcfStockIssuance, + OcfStockPlan, + OcfStockTransfer, + OcfVestingTerms, + 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 + 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: [], +}; + +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 parsedStrings; +void parsedGenericStrings; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 72936fd0..59abaaaf 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -6,8 +6,16 @@ import { authorizeIssuer, buildCreateIssuerCommand, CapTableBatch, + isContractId, + isOcfId, + isPartyId, + isSecurityId, OcpClient, OcpValidationError, + toContractId, + toOcfId, + toPartyId, + toSecurityId, withdrawAuthorization, type AppliedCommandContext, type AuthorizeIssuerResult, @@ -16,6 +24,7 @@ import { type CapTableBatchParams, type CapTableContractDetails, type CommandContext, + type ContractId, type ConversionTriggerFor, type ConvertibleConversionRight, type ConvertibleConversionTrigger, @@ -26,13 +35,16 @@ import { type OcfEntityDataMap, type OcfEntityType, type OcfFinancing, + type OcfId, type OcfIssuer, type OcfObject, type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, type OcfVestingStart, + type PartyId, type RatioConversionMechanism, + type SecurityId, type StockClassConversionRight, type SubmitAndWaitForTransactionTreeResponse, type WarrantExerciseTrigger, @@ -59,6 +71,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. @@ -157,6 +173,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 0cfd29aa..64e709dc 100644 --- a/test/publicApi/rootExports.test.ts +++ b/test/publicApi/rootExports.test.ts @@ -61,10 +61,6 @@ describe('package root exports', () => { 'toPartyId', 'toResolvedCantonConfig', 'toSecurityId', - 'unsafeToContractId', - 'unsafeToOcfId', - 'unsafeToPartyId', - 'unsafeToSecurityId', 'validateConfig', 'withdrawAuthorization', ]); diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 504f7edc..21ceaf14 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "22606cf5afc15d5e0f5209d72459950856d7aadf755b19c2bfe9946cc780df6f", + "fingerprint": "1caabaa91de95a131908411cae0834824b73336bc7c7262dc945d24c948117d4", "objects": [ { "discriminator": "CE_STAKEHOLDER_RELATIONSHIP", @@ -23,7 +23,7 @@ }, { "discriminator": "FINANCING", - "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;\"issuance_ids\":array(string);\"name\":string;readonly \"object_type\":string:\"FINANCING\")&object(readonly \"object_type\":string:\"FINANCING\"))" + "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;\"issuance_ids\":tuple(string,...string);\"name\":string;readonly \"object_type\":string:\"FINANCING\")&object(readonly \"object_type\":string:\"FINANCING\"))" }, { "discriminator": "ISSUER", @@ -75,7 +75,7 @@ }, { "discriminator": "TX_CONVERTIBLE_TRANSFER", - "signature": "intersection(object(\"amount\":object(\"amount\":string;\"currency\":string);\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_CONVERTIBLE_TRANSFER\";\"resulting_security_ids\":array(string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_CONVERTIBLE_TRANSFER\"))" + "signature": "intersection(object(\"amount\":object(\"amount\":string;\"currency\":string);\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_CONVERTIBLE_TRANSFER\";\"resulting_security_ids\":tuple(string,...string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_CONVERTIBLE_TRANSFER\"))" }, { "discriminator": "TX_EQUITY_COMPENSATION_ACCEPTANCE", @@ -91,15 +91,15 @@ }, { "discriminator": "TX_EQUITY_COMPENSATION_ISSUANCE", - "signature": "intersection(object(\"base_price\":object(\"amount\":string;\"currency\":string);\"compensation_type\":union(string:\"CSAR\"|string:\"SSAR\");\"exercise_price\"?:never)&object(\"base_price\"?:object(\"amount\":string;\"currency\":string);\"board_approval_date\"?:string;\"comments\"?:array(string);\"compensation_type\":union(string:\"CSAR\"|string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\"|string:\"RSU\"|string:\"SSAR\");\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"early_exercisable\"?:union(false|true);\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"expiration_date\":union(null|string);\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stock_class_id\"?:string;\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"termination_exercise_windows\":array(object(\"period\":number;\"period_type\":union(string:\"DAYS\"|string:\"MONTHS\"|string:\"YEARS\");\"reason\":union(string:\"INVOLUNTARY_DEATH\"|string:\"INVOLUNTARY_DISABILITY\"|string:\"INVOLUNTARY_OTHER\"|string:\"INVOLUNTARY_WITH_CAUSE\"|string:\"VOLUNTARY_GOOD_CAUSE\"|string:\"VOLUNTARY_OTHER\"|string:\"VOLUNTARY_RETIREMENT\")));\"vesting_terms_id\"?:string;\"vestings\"?:array(object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\"))" + "signature": "intersection(object(\"base_price\":object(\"amount\":string;\"currency\":string);\"compensation_type\":union(string:\"CSAR\"|string:\"SSAR\");\"exercise_price\"?:never)&object(\"base_price\"?:object(\"amount\":string;\"currency\":string);\"board_approval_date\"?:string;\"comments\"?:array(string);\"compensation_type\":union(string:\"CSAR\"|string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\"|string:\"RSU\"|string:\"SSAR\");\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"early_exercisable\"?:union(false|true);\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"expiration_date\":union(null|string);\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stock_class_id\"?:string;\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"termination_exercise_windows\":array(object(\"period\":number;\"period_type\":union(string:\"DAYS\"|string:\"MONTHS\"|string:\"YEARS\");\"reason\":union(string:\"INVOLUNTARY_DEATH\"|string:\"INVOLUNTARY_DISABILITY\"|string:\"INVOLUNTARY_OTHER\"|string:\"INVOLUNTARY_WITH_CAUSE\"|string:\"VOLUNTARY_GOOD_CAUSE\"|string:\"VOLUNTARY_OTHER\"|string:\"VOLUNTARY_RETIREMENT\")));\"vesting_terms_id\"?:string;\"vestings\"?:tuple(object(\"amount\":string;\"date\":string),...object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\"))" }, { "discriminator": "TX_EQUITY_COMPENSATION_ISSUANCE", - "signature": "intersection(object(\"base_price\"?:never;\"compensation_type\":string:\"RSU\";\"exercise_price\"?:never)&object(\"base_price\"?:object(\"amount\":string;\"currency\":string);\"board_approval_date\"?:string;\"comments\"?:array(string);\"compensation_type\":union(string:\"CSAR\"|string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\"|string:\"RSU\"|string:\"SSAR\");\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"early_exercisable\"?:union(false|true);\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"expiration_date\":union(null|string);\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stock_class_id\"?:string;\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"termination_exercise_windows\":array(object(\"period\":number;\"period_type\":union(string:\"DAYS\"|string:\"MONTHS\"|string:\"YEARS\");\"reason\":union(string:\"INVOLUNTARY_DEATH\"|string:\"INVOLUNTARY_DISABILITY\"|string:\"INVOLUNTARY_OTHER\"|string:\"INVOLUNTARY_WITH_CAUSE\"|string:\"VOLUNTARY_GOOD_CAUSE\"|string:\"VOLUNTARY_OTHER\"|string:\"VOLUNTARY_RETIREMENT\")));\"vesting_terms_id\"?:string;\"vestings\"?:array(object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\"))" + "signature": "intersection(object(\"base_price\"?:never;\"compensation_type\":string:\"RSU\";\"exercise_price\"?:never)&object(\"base_price\"?:object(\"amount\":string;\"currency\":string);\"board_approval_date\"?:string;\"comments\"?:array(string);\"compensation_type\":union(string:\"CSAR\"|string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\"|string:\"RSU\"|string:\"SSAR\");\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"early_exercisable\"?:union(false|true);\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"expiration_date\":union(null|string);\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stock_class_id\"?:string;\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"termination_exercise_windows\":array(object(\"period\":number;\"period_type\":union(string:\"DAYS\"|string:\"MONTHS\"|string:\"YEARS\");\"reason\":union(string:\"INVOLUNTARY_DEATH\"|string:\"INVOLUNTARY_DISABILITY\"|string:\"INVOLUNTARY_OTHER\"|string:\"INVOLUNTARY_WITH_CAUSE\"|string:\"VOLUNTARY_GOOD_CAUSE\"|string:\"VOLUNTARY_OTHER\"|string:\"VOLUNTARY_RETIREMENT\")));\"vesting_terms_id\"?:string;\"vestings\"?:tuple(object(\"amount\":string;\"date\":string),...object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\"))" }, { "discriminator": "TX_EQUITY_COMPENSATION_ISSUANCE", - "signature": "intersection(object(\"base_price\"?:never;\"compensation_type\":union(string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\");\"exercise_price\":object(\"amount\":string;\"currency\":string))&object(\"base_price\"?:object(\"amount\":string;\"currency\":string);\"board_approval_date\"?:string;\"comments\"?:array(string);\"compensation_type\":union(string:\"CSAR\"|string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\"|string:\"RSU\"|string:\"SSAR\");\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"early_exercisable\"?:union(false|true);\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"expiration_date\":union(null|string);\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stock_class_id\"?:string;\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"termination_exercise_windows\":array(object(\"period\":number;\"period_type\":union(string:\"DAYS\"|string:\"MONTHS\"|string:\"YEARS\");\"reason\":union(string:\"INVOLUNTARY_DEATH\"|string:\"INVOLUNTARY_DISABILITY\"|string:\"INVOLUNTARY_OTHER\"|string:\"INVOLUNTARY_WITH_CAUSE\"|string:\"VOLUNTARY_GOOD_CAUSE\"|string:\"VOLUNTARY_OTHER\"|string:\"VOLUNTARY_RETIREMENT\")));\"vesting_terms_id\"?:string;\"vestings\"?:array(object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\"))" + "signature": "intersection(object(\"base_price\"?:never;\"compensation_type\":union(string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\");\"exercise_price\":object(\"amount\":string;\"currency\":string))&object(\"base_price\"?:object(\"amount\":string;\"currency\":string);\"board_approval_date\"?:string;\"comments\"?:array(string);\"compensation_type\":union(string:\"CSAR\"|string:\"OPTION\"|string:\"OPTION_ISO\"|string:\"OPTION_NSO\"|string:\"RSU\"|string:\"SSAR\");\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"early_exercisable\"?:union(false|true);\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"expiration_date\":union(null|string);\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stock_class_id\"?:string;\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"termination_exercise_windows\":array(object(\"period\":number;\"period_type\":union(string:\"DAYS\"|string:\"MONTHS\"|string:\"YEARS\");\"reason\":union(string:\"INVOLUNTARY_DEATH\"|string:\"INVOLUNTARY_DISABILITY\"|string:\"INVOLUNTARY_OTHER\"|string:\"INVOLUNTARY_WITH_CAUSE\"|string:\"VOLUNTARY_GOOD_CAUSE\"|string:\"VOLUNTARY_OTHER\"|string:\"VOLUNTARY_RETIREMENT\")));\"vesting_terms_id\"?:string;\"vestings\"?:tuple(object(\"amount\":string;\"date\":string),...object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_ISSUANCE\"))" }, { "discriminator": "TX_EQUITY_COMPENSATION_RELEASE", @@ -115,7 +115,7 @@ }, { "discriminator": "TX_EQUITY_COMPENSATION_TRANSFER", - "signature": "intersection(object(\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_TRANSFER\";\"quantity\":string;\"resulting_security_ids\":array(string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_TRANSFER\"))" + "signature": "intersection(object(\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_TRANSFER\";\"quantity\":string;\"resulting_security_ids\":tuple(string,...string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_EQUITY_COMPENSATION_TRANSFER\"))" }, { "discriminator": "TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT", @@ -143,7 +143,7 @@ }, { "discriminator": "TX_STOCK_CONSOLIDATION", - "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_CONSOLIDATION\";\"reason_text\"?:string;\"resulting_security_id\":string;\"security_ids\":array(string))&object(readonly \"object_type\":string:\"TX_STOCK_CONSOLIDATION\"))" + "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_CONSOLIDATION\";\"reason_text\"?:string;\"resulting_security_id\":string;\"security_ids\":tuple(string,...string))&object(readonly \"object_type\":string:\"TX_STOCK_CONSOLIDATION\"))" }, { "discriminator": "TX_STOCK_CONVERSION", @@ -151,7 +151,7 @@ }, { "discriminator": "TX_STOCK_ISSUANCE", - "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"cost_basis\"?:object(\"amount\":string;\"currency\":string);\"custom_id\":string;\"date\":string;\"id\":string;\"issuance_type\"?:union(string:\"FOUNDERS_STOCK\"|string:\"RSA\");readonly \"object_type\":string:\"TX_STOCK_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"share_numbers_issued\"?:array(object(\"ending_share_number\":string;\"starting_share_number\":string));\"share_price\":object(\"amount\":string;\"currency\":string);\"stakeholder_id\":string;\"stock_class_id\":string;\"stock_legend_ids\":array(string);\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"vesting_terms_id\"?:string;\"vestings\"?:array(object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_STOCK_ISSUANCE\"))" + "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"cost_basis\"?:object(\"amount\":string;\"currency\":string);\"custom_id\":string;\"date\":string;\"id\":string;\"issuance_type\"?:union(string:\"FOUNDERS_STOCK\"|string:\"RSA\");readonly \"object_type\":string:\"TX_STOCK_ISSUANCE\";\"quantity\":string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"share_numbers_issued\"?:array(object(\"ending_share_number\":string;\"starting_share_number\":string));\"share_price\":object(\"amount\":string;\"currency\":string);\"stakeholder_id\":string;\"stock_class_id\":string;\"stock_legend_ids\":array(string);\"stock_plan_id\"?:string;\"stockholder_approval_date\"?:string;\"vesting_terms_id\"?:string;\"vestings\"?:tuple(object(\"amount\":string;\"date\":string),...object(\"amount\":string;\"date\":string)))&object(readonly \"object_type\":string:\"TX_STOCK_ISSUANCE\"))" }, { "discriminator": "TX_STOCK_PLAN_POOL_ADJUSTMENT", @@ -175,7 +175,7 @@ }, { "discriminator": "TX_STOCK_TRANSFER", - "signature": "intersection(object(\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_TRANSFER\";\"quantity\":string;\"resulting_security_ids\":array(string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_STOCK_TRANSFER\"))" + "signature": "intersection(object(\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_TRANSFER\";\"quantity\":string;\"resulting_security_ids\":tuple(string,...string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_STOCK_TRANSFER\"))" }, { "discriminator": "TX_VESTING_ACCELERATION", @@ -211,7 +211,7 @@ }, { "discriminator": "TX_WARRANT_TRANSFER", - "signature": "intersection(object(\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_WARRANT_TRANSFER\";\"quantity\":string;\"resulting_security_ids\":array(string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_WARRANT_TRANSFER\"))" + "signature": "intersection(object(\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_WARRANT_TRANSFER\";\"quantity\":string;\"resulting_security_ids\":tuple(string,...string);\"security_id\":string)&object(readonly \"object_type\":string:\"TX_WARRANT_TRANSFER\"))" }, { "discriminator": "VALUATION", 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/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 0617c149..f1d9d239 100644 --- a/test/schemaAlignment/schemaConformance.test.ts +++ b/test/schemaAlignment/schemaConformance.test.ts @@ -4,6 +4,7 @@ import os from 'os'; import path from 'path'; import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { + compareCanonicalNonEmptyArrays, compareCanonicalOcfPropertySets, compareCodeUnits, compareConditionalRegistry, @@ -14,10 +15,13 @@ import { discoverConditionalPathsInValue, getNamedTypeProperty, getObjectSchemaDiscriminators, + inventoryCanonicalOcfNonEmptyArrays, inventoryCanonicalOcfObjects, inventoryCanonicalOcfPropertySets, inventoryCanonicalOcfPublicTypes, + inventoryPinnedOcfNonEmptyArrays, inventoryPinnedOcfObjectProperties, + inventoryPinnedOcfUniqueArrays, inventoryReachableObjectSchemas, inventoryRetiredPlanSecuritySchemaPairs, normalizeFingerprintText, @@ -29,8 +33,11 @@ import { } from './schemaConformanceHarness'; import { CANONICAL_PROPERTY_PARITY_EXCLUSIONS, + EXPECTED_NON_EMPTY_ARRAY_REFINEMENTS, EXPECTED_SEMANTIC_REFINEMENTS, OCF_CONDITIONAL_COVERAGE, + PINNED_CANONICAL_NON_EMPTY_ARRAYS, + PINNED_CANONICAL_UNIQUE_ARRAYS, PINNED_REACHABLE_SCHEMA_FINGERPRINT, } from './schemaConformanceRegistry'; @@ -69,6 +76,36 @@ function createSyntheticOcfRepo(sourceOutput: string, builtOutput?: string): str return repoRoot; } +function createSyntheticNonEmptySchemaRoot(keyword: 'anyOf' | 'oneOf'): string { + const schemaRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ocp-non-empty-schema-')); + syntheticRepoRoots.push(schemaRoot); + const objectRoot = path.join(schemaRoot, 'objects'); + fs.mkdirSync(objectRoot, { recursive: true }); + fs.writeFileSync( + path.join(objectRoot, 'Synthetic.schema.json'), + JSON.stringify({ + [keyword]: [ + { + properties: { + object_type: { const: 'SYNTHETIC' }, + branch_only_items: { type: 'array', minItems: 1 }, + items: { type: 'array', minItems: 2 }, + }, + type: 'object', + }, + { + properties: { + object_type: { const: 'SYNTHETIC' }, + items: { type: 'array', minItems: 1 }, + }, + type: 'object', + }, + ], + }) + ); + return schemaRoot; +} + function simpleSyntheticObject(memberDeclaration: string, prefix = ''): string { return `${prefix} export type OcfObject = { @@ -646,6 +683,36 @@ 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', + optionalProperties: [], + requiredProperties: ['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.each([ { canonical: { optionalProperties: ['member'], requiredProperties: ['object_type'] }, @@ -674,6 +741,98 @@ describe('schema-driven OCF conformance guardrail', () => { ]); }); + it('matches every pinned canonical minItems constraint to an SDK NonEmptyArray property', () => { + const canonicalInventory = inventoryCanonicalOcfPropertySets(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( + EXPECTED_NON_EMPTY_ARRAY_REFINEMENTS.map(({ discriminator, kind, property }) => ({ + discriminator, + kind, + property, + })) + ); + }); + + 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) => { + expect(inventoryPinnedOcfNonEmptyArrays(createSyntheticNonEmptySchemaRoot(keyword))).toEqual([ + { + discriminator: 'SYNTHETIC', + minItems: 1, + property: 'items', + schemaPath: 'schema/objects/Synthetic.schema.json', + }, + ]); + } + ); + + 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('detects canonical OcfObject member-type drift without a property-name change', () => { const stringMemberInventory = inventoryCanonicalOcfObjects( createSyntheticOcfRepo( diff --git a/test/schemaAlignment/schemaConformanceHarness.ts b/test/schemaAlignment/schemaConformanceHarness.ts index 1dd58230..30bea451 100644 --- a/test/schemaAlignment/schemaConformanceHarness.ts +++ b/test/schemaAlignment/schemaConformanceHarness.ts @@ -59,6 +59,28 @@ export interface PinnedOcfObjectPropertyInventoryEntry { schemaPath: string; } +export interface NonEmptyArrayPropertyInventoryEntry { + discriminator: string; + property: string; +} + +export interface PinnedOcfNonEmptyArrayInventoryEntry extends NonEmptyArrayPropertyInventoryEntry { + minItems: number; + schemaPath: string; +} + +export interface PinnedOcfUniqueArrayInventoryEntry extends NonEmptyArrayPropertyInventoryEntry { + 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 RetiredPlanSecuritySchemaPair { canonicalDiscriminator: string; retiredDiscriminator: string; @@ -616,6 +638,124 @@ function inventoryComposedObjectProperties( return { properties, requiredProperties }; } +function guaranteedMinItems(schema: unknown): number { + if (!isJsonObject(schema)) return 0; + + let minimum = + typeof schema.minItems === 'number' && Number.isInteger(schema.minItems) && schema.minItems >= 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)); + } + + 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 Map(); + collectUnconditionalTopLevelNonEmptyArrays(branch, collected); + return collected; + }); + const [firstBranch, ...remainingBranches] = branchProperties; + if (!firstBranch) continue; + + for (const [property, firstMinimum] of firstBranch) { + const minima = [firstMinimum]; + let guaranteedInEveryBranch = true; + for (const branch of remainingBranches) { + const minimum = branch.get(property); + if (minimum === undefined) { + guaranteedInEveryBranch = false; + break; + } + minima.push(minimum); + } + if (!guaranteedInEveryBranch) continue; + + const guaranteedMinimum = Math.min(...minima); + properties.set(property, Math.max(properties.get(property) ?? 0, guaranteedMinimum)); + } + } +} + +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; @@ -764,6 +904,126 @@ export function inventoryPinnedOcfObjectProperties(schemaRoot: string): PinnedOc .sort((left, right) => compareCodeUnits(left.discriminator, 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]) => compareCodeUnits(left, right)) + .map(([property, minItems]) => ({ discriminator, minItems, property, schemaPath: relativeSchemaPath })) + ); + }) + .sort( + (left, right) => + compareCodeUnits(left.discriminator, right.discriminator) || compareCodeUnits(left.property, right.property) + ); +} + +/** 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}`; +} + +/** Compare pinned non-empty arrays with the canonical public properties that use NonEmptyArray. */ +export function compareCanonicalNonEmptyArrays( + canonicalInventory: readonly CanonicalOcfPropertySet[], + 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) => + compareCodeUnits(left.discriminator, right.discriminator) || + compareCodeUnits(left.kind, right.kind) || + compareCodeUnits(left.property, right.property) + ); +} + /** Derive deprecated PlanSecurity wrapper-to-canonical mappings from the pinned schema graph. */ export function inventoryRetiredPlanSecuritySchemaPairs(schemaRoot: string): RetiredPlanSecuritySchemaPair[] { const objectRoot = path.join(schemaRoot, 'objects'); @@ -1703,6 +1963,63 @@ export function inventoryCanonicalOcfPublicTypes( }; } +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(compareCodeUnits)) { + 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) => + compareCodeUnits(left.discriminator, right.discriminator) || compareCodeUnits(left.property, right.property) + ); +} + function groupCanonicalObjectSignatures(inventory: CanonicalOcfPublicTypeInventory): Map { const signaturesByDiscriminator = new Map(); for (const entry of inventory.objects) { diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index a388404e..d5654d6a 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -2,6 +2,8 @@ import type { CanonicalPropertyParityExclusion, ConditionalCoverageRegistration, CoverageReference, + PinnedOcfNonEmptyArrayInventoryEntry, + PinnedOcfUniqueArrayInventoryEntry, SemanticRefinement, } from './schemaConformanceHarness'; @@ -37,6 +39,122 @@ 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', + }, +]; + +/** 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', + }, +]; + +/** Deliberate SDK non-empty refinements beyond the pinned schema's array cardinality. */ +export const EXPECTED_NON_EMPTY_ARRAY_REFINEMENTS = [ + { + discriminator: 'TX_CONVERTIBLE_CONVERSION', + kind: 'sdk-only' as const, + property: 'resulting_security_ids', + rationale: + 'A completed convertible conversion must create at least one resulting security even though the pinned primitive schema omits minItems.', + }, +] as const; + const CONDITIONAL_WITNESS_FILE = 'test/schemaAlignment/conditionalBranchWitnesses.test.ts'; const CORE_CONDITIONAL_SHAPES_FILE = 'test/schemaAlignment/coreConditionalShapes.test.ts'; diff --git a/test/types/branded.test.ts b/test/types/branded.test.ts new file mode 100644 index 00000000..03f53bcb --- /dev/null +++ b/test/types/branded.test.ts @@ -0,0 +1,85 @@ +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.each(brandCases)('%s reports hostile objects without invoking user code', (name, _guard, convert) => { + const hostileToString = jest.fn(() => { + throw new Error('hostile toString invoked'); + }); + const hostileObject = { toString: hostileToString }; + + expect(() => convert(hostileObject)).toThrow(`Invalid ${name}`); + expect(hostileToString).not.toHaveBeenCalled(); + + const proxyTrap = jest.fn(() => { + throw new Error('proxy trap invoked'); + }); + const hostileProxy = new Proxy( + {}, + { + get: proxyTrap, + getOwnPropertyDescriptor: proxyTrap, + getPrototypeOf: proxyTrap, + ownKeys: proxyTrap, + } + ); + + expect(() => convert(hostileProxy)).toThrow(`Invalid ${name}`); + expect(proxyTrap).not.toHaveBeenCalled(); + }); + + it('bounds diagnostics for large invalid objects', () => { + const largeObject = Object.fromEntries(Array.from({ length: 1_000 }, (_, index) => [`field_${index}`, index])); + + try { + toContractId(largeObject); + throw new Error('Expected toContractId to reject a non-string object'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('Invalid ContractId'); + expect((error as Error).message.length).toBeLessThan(256); + } + }); + + 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/test/types/nonEmptyArrays.types.ts b/test/types/nonEmptyArrays.types.ts new file mode 100644 index 00000000..8a2353e2 --- /dev/null +++ b/test/types/nonEmptyArrays.types.ts @@ -0,0 +1,88 @@ +import type { + NonEmptyArray, + OcfConvertibleIssuance, + OcfConvertibleTransfer, + OcfEquityCompensationIssuance, + OcfEquityCompensationTransfer, + OcfFinancing, + OcfStockConsolidation, + OcfStockIssuance, + OcfStockPlan, + OcfStockTransfer, + OcfVestingTerms, + 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 + 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: [], +}; + +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 parsedStrings; +void parsedGenericStrings; diff --git a/test/utils/arrayCardinality.test.ts b/test/utils/arrayCardinality.test.ts new file mode 100644 index 00000000..e79a665e --- /dev/null +++ b/test/utils/arrayCardinality.test.ts @@ -0,0 +1,197 @@ +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 { + 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('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 }, + 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 4c72bea6..880f6163 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -1,13 +1,17 @@ /** Unit tests for typeConversions utility functions. */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, ensureArray, monetaryToDaml, + nonEmptyArrayOrUndefined, normalizeNumericString, parseDamlMap, + quantityTransferToNative, + toNonEmptyArray, + toNonEmptyStringArray, } from '../../src/utils/typeConversions'; describe('normalizeNumericString', () => { @@ -123,6 +127,95 @@ describe('ensureArray', () => { }); }); +describe('non-empty array helpers', () => { + test('returns a non-empty tuple without changing its values', () => { + 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]; + 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(() => toNonEmptyStringArray([], 'transfer.resulting_security_ids')).toThrow(OcpValidationError); + expect(() => toNonEmptyStringArray([], '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 = () => 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: + values === null || values === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + fieldPath: 'transfer.resulting_security_ids', + expectedType: 'array', + receivedValue: values, + }); + } + + expect(invokeOptional).toThrow(OcpValidationError); + try { + invokeOptional(); + } catch (error) { + expect(error).toMatchObject({ + code: + values === null || values === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuance.vestings', + expectedType: 'array', + receivedValue: values, + }); + } + }); + + test('converts optional arrays to a non-empty tuple or undefined', () => { + expect(nonEmptyArrayOrUndefined([], 'items', (value) => value)).toBeUndefined(); + expect(nonEmptyArrayOrUndefined(['only'], 'items', (value) => value)).toEqual(['only']); + }); +}); + +describe('quantityTransferToNative', () => { + it.each([ + ['stockTransfer.date', 'stockTransfer.resulting_security_ids'], + ['warrantTransfer.date', 'warrantTransfer.resulting_security_ids'], + ['equityCompensationTransfer.date', 'equityCompensationTransfer.resulting_security_ids'], + ['date', 'resulting_security_ids'], + ])('reports empty results at the sibling 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', () => { 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({ 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,