Files
lislgosms/tools/quality/verify-operations-r2.mjs

97 lines
5.0 KiB
JavaScript

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.`);