import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import ts from '../../api/node_modules/typescript/lib/typescript.js'; const root = process.cwd(); const manifest = JSON.parse( readFileSync(resolve(root, 'docs/contracts/sms-config-r3-methods.json'), 'utf8'), ); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); const sha256 = (value) => createHash('sha256').update(value).digest('hex'); function loadSource(relativePath) { const filePath = resolve(root, relativePath); const source = readFileSync(filePath, 'utf8'); const file = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); return { source, file }; } function loadClass(relativePath, className) { const { source, file } = loadSource(relativePath); const declaration = file.statements.find( (statement) => ts.isClassDeclaration(statement) && statement.name?.text === className, ); if (!declaration || !ts.isClassDeclaration(declaration)) { throw new Error(`${className} not found in ${relativePath}`); } return { source, file, methods: new Map( declaration.members.filter(ts.isMethodDeclaration).map((method) => [ method.name.getText(file), method, ]), ), }; } const reverseReplacements = { application: { 'this.lifecycle.markTimedOutDownstreamConnections': 'this.markTimedOutDownstreamConnections', 'this.lifecycle.getApplicationDeactivationPreview': 'this.getApplicationDeactivationPreview', 'this.lifecycle.writeOperationLog': 'this.writeOperationLog', }, signature: { 'this.reportValidation.validateSignatureReportValues': 'this.validateSignatureReportValues', 'this.reportValidation.withReportRequirementSnapshot': 'this.withReportRequirementSnapshot', 'this.reportValidation.syncSignatureReportValues': 'this.syncSignatureReportValues', 'this.audit.createAuditRecord': 'this.createAuditRecord', }, drainage: { 'this.reportValidation.validateDrainageReportValues': 'this.validateDrainageReportValues', 'this.reportValidation.activateDrainageReporting': 'this.activateDrainageReporting', 'this.reportValidation.suspendDrainageReporting': 'this.suspendDrainageReporting', 'this.audit.createAuditRecord': 'this.createAuditRecord', 'this.audit.reviewDrainageInfo': 'this.reviewDrainageInfo', }, template: { 'this.audit.createAuditRecord': 'this.createAuditRecord', }, audit: { 'this.lifecycle.writeOperationLog': 'this.writeOperationLog', 'this.reportValidation.activateDrainageReporting': 'this.activateDrainageReporting', 'this.reportValidation.suspendDrainageReporting': 'this.suspendDrainageReporting', }, reportValidation: { 'this.applications.getApplicationReportFields': 'this.getApplicationReportFields', }, }; function normalizeDomainBody(domain, method, file) { let printed = printer.printNode(ts.EmitHint.Unspecified, method.body, file); for (const [current, original] of Object.entries(reverseReplacements[domain] ?? {})) { printed = printed.split(current).join(original); } if (method.name.getText(file) === 'finalizeDisablingApplication') { printed = printed .split("SmsApplicationLifecycleService['getApplicationDeactivationPreview']") .join("SmsConfigService['getApplicationDeactivationPreview']"); } return printed; } const domainMethods = new Map(); for (const [domain, definition] of Object.entries(manifest.domains)) { const loaded = loadClass(definition.file, definition.className); for (const name of definition.methods) { const method = loaded.methods.get(name); if (!method || !method.body) throw new Error(`Missing R3 domain method: ${domain}.${name}`); if (domainMethods.has(name)) throw new Error(`Duplicate R3 domain method: ${name}`); domainMethods.set(name, { domain, method, file: loaded.file, source: loaded.source }); } if (loaded.methods.size !== definition.methods.length) { throw new Error(`Unexpected method found in R3 domain: ${domain}`); } } const expectedMethods = [...manifest.publicMethods, ...manifest.internalMethods]; for (const expected of expectedMethods) { const actual = domainMethods.get(expected.name); if (!actual) throw new Error(`R3 implementation missing: ${expected.name}`); if (actual.domain !== expected.domain) { throw new Error(`${expected.name} moved to unexpected R3 domain`); } const bodyHash = sha256(normalizeDomainBody(actual.domain, actual.method, actual.file)); if (bodyHash !== expected.canonicalBodySha256) { throw new Error(`${expected.name} implementation changed during R3 split`); } } if (domainMethods.size !== expectedMethods.length) { throw new Error('Unexpected R3 implementation method found'); } const facade = loadClass('api/src/sms-config/sms-config.service.ts', 'SmsConfigService'); const facadePublicMethods = new Map( [...facade.methods].filter(([, method]) => !method.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword), ), ); for (const expected of manifest.publicMethods) { const method = facadePublicMethods.get(expected.name); if (!method || !method.body) throw new Error(`SmsConfigService facade method missing: ${expected.name}`); const signature = facade.source.slice(method.getStart(facade.file), method.body.getStart(facade.file)).trim(); if (signature !== expected.signature) throw new Error(`${expected.name} facade signature changed`); } if (facadePublicMethods.size !== manifest.publicMethods.length) { throw new Error('Unexpected SmsConfigService facade method found'); } const contracts = loadSource('api/src/sms-config/sms-config.contracts.ts'); const actualContracts = new Map( contracts.file.statements .filter((statement) => ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) .map((statement) => [statement.name.text, sha256(statement.getText(contracts.file))]), ); for (const expected of manifest.contracts) { if (actualContracts.get(expected.name) !== expected.sha256) { throw new Error(`SMS config contract changed during R3 split: ${expected.name}`); } } if (actualContracts.size !== manifest.contracts.length) { throw new Error('Unexpected SMS config contract found'); } const helpers = loadSource('api/src/sms-config/sms-config.helpers.ts'); const actualHelpers = new Map( helpers.file.statements .filter( (statement) => ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isFunctionDeclaration(statement) || ts.isVariableStatement(statement), ) .map((statement) => [ ts.isVariableStatement(statement) ? statement.declarationList.declarations[0].name.getText(helpers.file) : statement.name.text, sha256(statement.getText(helpers.file).replace(/^export\s+/, '')), ]), ); for (const expected of [...manifest.helpers, ...manifest.sharedDeclarations]) { if (actualHelpers.get(expected.name) !== expected.sha256) { throw new Error(`SMS config helper changed during R3 split: ${expected.name}`); } } if (actualHelpers.size !== manifest.helpers.length + manifest.sharedDeclarations.length) { throw new Error('Unexpected SMS config helper found'); } for (const controller of [ 'api/src/sms-config/admin-sms-config.controller.ts', 'api/src/sms-config/client-sms-config.controller.ts', 'api/src/send-chain/gateway-events.controller.ts', ]) { const loaded = loadSource(controller); for (const statement of loaded.file.statements.filter(ts.isImportDeclaration)) { if (!statement.moduleSpecifier.getText(loaded.file).includes('sms-config.service')) continue; const names = statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) ? statement.importClause.namedBindings.elements.map((element) => element.name.text) : []; if (names.some((name) => name.endsWith('Dto') || name.endsWith('ListQuery'))) { throw new Error(`${controller} still imports DTOs from the implementation facade`); } } } console.log( `R3 SMS configuration facade verified: ${manifest.publicMethods.length} public methods, ${manifest.internalMethods.length} internal methods, ${manifest.contracts.length} contracts and ${manifest.helpers.length + manifest.sharedDeclarations.length} helpers/constants; implementations unchanged.`, );