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/channels-r5-methods.json'), 'utf8')); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); const sha256 = (value) => createHash('sha256').update(value).digest('hex'); function load(relativePath) { const filePath = resolve(root, relativePath); const source = readFileSync(filePath, 'utf8'); return { source, file: ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS), }; } function loadClass(relativePath, className) { const loaded = load(relativePath); const declaration = loaded.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 { ...loaded, declaration, methods: new Map( declaration.members.filter(ts.isMethodDeclaration).map((method) => [ method.name.getText(loaded.file), method, ]), ), }; } const reverseReplacements = { configuration: { 'this.connection.requestChannelConnection': 'this.requestChannelConnection', 'this.connection.requestChannelDisconnection': 'this.requestChannelDisconnection', }, testing: { 'this.connection.getGatewaySubmitQueue': 'this.getGatewaySubmitQueue', 'this.connection.publishGatewaySubmitCommand': 'this.publishGatewaySubmitCommand', }, deletion: { 'this.configuration.changeChannelStatus': 'this.changeChannelStatus', }, }; function normalizedBody(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); } return printed; } const domainMethods = new Map(); const loadedDomains = new Map(); for (const [domain, definition] of Object.entries(manifest.domains)) { const loaded = loadClass(definition.file, definition.className); loadedDomains.set(domain, loaded); for (const name of definition.methods) { const method = loaded.methods.get(name); if (!method?.body) throw new Error(`Missing R5 channel method: ${domain}.${name}`); if (domainMethods.has(name)) throw new Error(`Duplicate R5 channel method: ${name}`); domainMethods.set(name, { domain, method, file: loaded.file }); } if (loaded.methods.size !== definition.methods.length) { throw new Error(`Unexpected method found in R5 channel domain: ${domain}`); } } const expectedMethods = [...manifest.publicMethods, ...manifest.internalMethods]; for (const expected of expectedMethods) { const actual = domainMethods.get(expected.name); if (!actual) throw new Error(`R5 channel implementation missing: ${expected.name}`); if (actual.domain !== expected.domain) throw new Error(`${expected.name} moved to unexpected R5 domain`); if (sha256(normalizedBody(actual.domain, actual.method, actual.file)) !== expected.canonicalBodySha256) { throw new Error(`${expected.name} implementation changed during R5 split`); } } if (domainMethods.size !== expectedMethods.length) { throw new Error('Unexpected R5 channel implementation method found'); } const facade = loadClass('api/src/channels/channels.service.ts', 'ChannelsService'); 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?.body) throw new Error(`ChannelsService 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 ChannelsService facade method found'); } function declarationMap(relativePath) { const loaded = load(relativePath); const result = new Map(); for (const statement of loaded.file.statements) { if (ts.isFunctionDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) { if (statement.name) { result.set(statement.name.text, sha256(statement.getText(loaded.file).replace(/^export\s+/, ''))); } } else if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { result.set( declaration.name.getText(loaded.file), sha256(statement.getText(loaded.file).replace(/^export\s+/, '')), ); } } } return result; } const contracts = declarationMap('api/src/channels/channels.contracts.ts'); for (const expected of manifest.contracts) { if (contracts.get(expected.name) !== expected.sha256) { throw new Error(`Channel contract changed during R5 split: ${expected.name}`); } } if (contracts.size !== manifest.contracts.length) throw new Error('Unexpected channel contract found'); const helpers = declarationMap('api/src/channels/channels.helpers.ts'); for (const expected of [...manifest.helpers, ...manifest.sharedDeclarations]) { if (helpers.get(expected.name) !== expected.sha256) { throw new Error(`Channel helper changed during R5 split: ${expected.name}`); } } if (helpers.size !== manifest.helpers.length + manifest.sharedDeclarations.length) { throw new Error('Unexpected channel helper found'); } const connection = loadedDomains.get('connection'); for (const token of [ 'gatewayConnectionQueue', 'gatewaySubmitQueue', 'connectionTimeoutTimer', 'gatewayStartupReconnectTimer', 'gatewayReconcileTimer', '/connections/connect', '/connections/disconnect', ]) { if (!connection.source.includes(token)) throw new Error(`R5 connection side-effect invariant missing: ${token}`); } const configuration = loadedDomains.get('configuration').source; if (!configuration.includes('channelConnectionSettingsChanged')) { throw new Error('R5 non-connection edit reconnect guard missing'); } const testing = loadedDomains.get('testing').source; const helperSource = load('api/src/channels/channels.helpers.ts').source; if (!testing.includes('normalizeTestPhones') || !helperSource.includes('maxAttempts: 1')) { throw new Error('R5 channel test safety invariant missing'); } const controller = load('api/src/channels/channels.controller.ts'); for (const statement of controller.file.statements.filter(ts.isImportDeclaration)) { if (!statement.moduleSpecifier.getText(controller.file).includes('channels.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'))) { throw new Error('ChannelsController still imports DTOs from the implementation facade'); } } console.log( `R5 channels 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; side-effect invariants preserved.`, );