181 lines
7.5 KiB
JavaScript
181 lines
7.5 KiB
JavaScript
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.`,
|
|
);
|