import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import ts from 'typescript'; const root = process.cwd(); const contract = JSON.parse(readFileSync(resolve(root, 'docs/contracts/operations-r2-methods.json'), 'utf8')); function sha(value) { return createHash('sha256').update(value).digest('hex'); } function loadSource(relativePath) { const path = resolve(root, relativePath); const source = readFileSync(path, 'utf8'); return { source, file: ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) }; } function classMethods(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`); return new Map( declaration.members.filter(ts.isMethodDeclaration).map((method) => { if (!method.body) throw new Error(`${className}.${method.name.getText(file)} has no body`); return [method.name.getText(file), { signatureSha256: sha(source.slice(method.getStart(file), method.body.getStart(file))), bodySha256: sha(method.body.getText(file)), }]; }), ); } const queryClasses = { messages: ['api/src/operations/queries/messages.queries.ts', 'OperationsMessageQueries'], uplink: ['api/src/operations/queries/uplink.queries.ts', 'OperationsUplinkQueries'], dashboard: ['api/src/operations/queries/dashboard.queries.ts', 'OperationsDashboardQueries'], quality: ['api/src/operations/queries/quality.queries.ts', 'OperationsQualityQueries'], logs: ['api/src/operations/queries/logs.queries.ts', 'OperationsLogQueries'], downstream: ['api/src/operations/queries/downstream.queries.ts', 'OperationsDownstreamQueries'], trace: ['api/src/operations/queries/trace.queries.ts', 'OperationsTraceQueries'], }; const queryMethods = new Map(); for (const [group, [path, className]] of Object.entries(queryClasses)) { for (const [name, hashes] of classMethods(path, className)) { if (queryMethods.has(name)) throw new Error(`Duplicate R2 query method: ${name}`); queryMethods.set(name, { group, ...hashes }); } } const facadeMethods = classMethods('api/src/operations/operations.service.ts', 'OperationsService'); for (const expected of contract.methods) { const queryMethod = queryMethods.get(expected.name); if (!queryMethod) throw new Error(`R2 query method missing: ${expected.name}`); if (queryMethod.group !== expected.group) throw new Error(`${expected.name}: moved to unexpected group`); if (queryMethod.signatureSha256 !== expected.signatureSha256) throw new Error(`${expected.name}: query signature changed`); if (queryMethod.bodySha256 !== expected.bodySha256) throw new Error(`${expected.name}: query behavior changed`); if (!expected.isPrivate) { const facadeMethod = facadeMethods.get(expected.name); if (!facadeMethod) throw new Error(`OperationsService facade method missing: ${expected.name}`); if (facadeMethod.signatureSha256 !== expected.signatureSha256) throw new Error(`${expected.name}: facade signature changed`); } } if (queryMethods.size !== contract.methods.length) throw new Error('Unexpected R2 query methods found'); if (facadeMethods.size !== contract.methods.filter((method) => !method.isPrivate).length) { throw new Error('Unexpected OperationsService facade methods found'); } const contractsSource = loadSource('api/src/operations/operations.contracts.ts').file; const actualContracts = new Map( contractsSource.statements.filter(ts.isInterfaceDeclaration) .map((statement) => [statement.name.text, sha(statement.getText(contractsSource))]), ); for (const [name, expectedHash] of Object.entries(contract.contracts)) { if (actualContracts.get(name) !== expectedHash) throw new Error(`Operations contract changed: ${name}`); } const helpersSource = loadSource('api/src/operations/operations.helpers.ts').file; const actualHelpers = new Map(); for (const statement of helpersSource.statements) { if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { actualHelpers.set(statement.name.text, sha(statement.body.getText(helpersSource))); } else if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { actualHelpers.set(declaration.name.getText(helpersSource), sha(declaration.initializer?.getText(helpersSource) ?? '')); } } } for (const [name, expectedHash] of Object.entries(contract.helpers)) { if (actualHelpers.get(name) !== expectedHash) throw new Error(`Operations helper changed: ${name}`); } if (actualHelpers.size !== Object.keys(contract.helpers).length) throw new Error('Unexpected Operations helpers found'); console.log(`R2 operations facade verified: ${facadeMethods.size} public methods, ${queryMethods.size - facadeMethods.size} private methods, ${actualHelpers.size} helpers; signatures and implementations unchanged.`);