feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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/admin-api-r1-methods.json'), 'utf8'));
|
||||
|
||||
function objectProperties(relativePath, variableNames) {
|
||||
const path = resolve(root, relativePath);
|
||||
const source = readFileSync(path, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const result = [];
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (!variableNames.includes(declaration.name.getText(sourceFile)) || !ts.isObjectLiteralExpression(declaration.initializer)) continue;
|
||||
for (const property of declaration.initializer.properties) {
|
||||
result.push({
|
||||
name: property.name?.getText(sourceFile),
|
||||
implementationSha256: createHash('sha256').update(property.getText(sourceFile)).digest('hex'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function functionBodies(relativePath) {
|
||||
const path = resolve(root, relativePath);
|
||||
const source = readFileSync(path, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const result = new Map();
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) {
|
||||
result.set(
|
||||
statement.name.text,
|
||||
createHash('sha256').update(statement.body.getText(sourceFile)).digest('hex'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertExactContract(label, expected, actual) {
|
||||
const expectedByName = new Map(expected.map((item) => [item.name, item.implementationSha256]));
|
||||
const actualByName = new Map(actual.map((item) => [item.name, item.implementationSha256]));
|
||||
const missing = [...expectedByName.keys()].filter((name) => !actualByName.has(name));
|
||||
const unexpected = [...actualByName.keys()].filter((name) => !expectedByName.has(name));
|
||||
const changed = [...expectedByName.entries()]
|
||||
.filter(([name, hash]) => actualByName.get(name) !== undefined && actualByName.get(name) !== hash)
|
||||
.map(([name]) => name);
|
||||
if (missing.length || unexpected.length || changed.length) {
|
||||
throw new Error(`${label} contract changed: missing=[${missing}], unexpected=[${unexpected}], implementationChanged=[${changed}]`);
|
||||
}
|
||||
}
|
||||
|
||||
const adminMethods = [
|
||||
...objectProperties('src/api/admin/identity.api.ts', ['adminIdentityApi']),
|
||||
...objectProperties('src/api/admin/channels-reports.api.ts', ['adminChannelsReportsApi']),
|
||||
...objectProperties('src/api/admin/operations.api.ts', ['adminOperationsApi']),
|
||||
...objectProperties('src/api/admin/governance.api.ts', ['adminGovernanceApi']),
|
||||
...objectProperties('src/api/admin/files.api.ts', ['adminFilesApi']),
|
||||
];
|
||||
const clientMethods = objectProperties('src/api/client/client.api.ts', ['clientApi']);
|
||||
const sessionMethods = objectProperties('src/api/admin/session.api.ts', ['portalSessionApi']);
|
||||
|
||||
assertExactContract('adminApi', contract.objects.adminApi, adminMethods);
|
||||
assertExactContract('clientApi', contract.objects.clientApi, clientMethods);
|
||||
assertExactContract('portalSessionApi', contract.objects.portalSessionApi, sessionMethods);
|
||||
|
||||
const coreFunctions = functionBodies('src/api/core/httpClient.ts');
|
||||
for (const [name, expectedHash] of Object.entries(contract.coreFunctions)) {
|
||||
if (coreFunctions.get(name) !== expectedHash) {
|
||||
throw new Error(`Shared HTTP behavior changed: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const facade = readFileSync(resolve(root, 'src/api/adminApi.ts'), 'utf8');
|
||||
for (const stableExport of ['adminApi', 'clientApi', 'portalSessionApi', 'fileDownloadUrl']) {
|
||||
if (!facade.includes(stableExport)) throw new Error(`Stable facade export is missing: ${stableExport}`);
|
||||
}
|
||||
|
||||
console.log(`R1 API facade verified: ${adminMethods.length} admin methods, ${clientMethods.length} client methods, ${sessionMethods.length} session methods and ${coreFunctions.size} HTTP helpers; all implementations unchanged.`);
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const contractPath = path.join(root, 'docs/contracts/admin-channels-r11.json');
|
||||
const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
|
||||
function requirePattern(source, pattern, message) {
|
||||
if (!pattern.test(source)) failures.push(message);
|
||||
}
|
||||
|
||||
const entry = read(contract.stableEntry);
|
||||
const entryLines = entry.split(/\r?\n/).length;
|
||||
requirePattern(
|
||||
entry,
|
||||
new RegExp(`export\\s+function\\s+${contract.stableExport}\\s*\\(`),
|
||||
`stable export ${contract.stableExport} is missing`,
|
||||
);
|
||||
if (entryLines > contract.maxStableEntryLines) {
|
||||
failures.push(`stable entry has ${entryLines} lines; maximum is ${contract.maxStableEntryLines}`);
|
||||
}
|
||||
if (!entry.includes(`import '${contract.styleImport}';`)) {
|
||||
failures.push(`stable entry does not import ${contract.styleImport}`);
|
||||
}
|
||||
|
||||
for (const [file, exports] of Object.entries(contract.modules)) {
|
||||
const source = read(file);
|
||||
for (const exportedName of exports) {
|
||||
requirePattern(
|
||||
source,
|
||||
new RegExp(`export\\s+(?:function|const|class)\\s+${exportedName}\\b`),
|
||||
`${file} is missing export ${exportedName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, methods] of Object.entries(contract.apiCalls)) {
|
||||
const source = read(file);
|
||||
for (const method of methods) {
|
||||
requirePattern(source, new RegExp(`adminApi\\.${method}\\b`), `${file} is missing adminApi.${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const style = read(contract.styleFile);
|
||||
const globalStyle = read('src/styles/global.css');
|
||||
const adminStyle = read('src/styles/admin.css');
|
||||
for (const selector of contract.pageStyleSelectors) {
|
||||
if (!style.includes(selector)) failures.push(`${contract.styleFile} is missing ${selector}`);
|
||||
if (globalStyle.includes(selector)) failures.push(`src/styles/global.css still contains page selector ${selector}`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptGlobal) {
|
||||
if (!globalStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from global.css`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptInAdmin) {
|
||||
if (!adminStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from admin.css`);
|
||||
if (globalStyle.includes(selector)) failures.push(`shared selector ${selector} still exists in global.css`);
|
||||
}
|
||||
for (const label of contract.interactionLabels) {
|
||||
const found = [entry, ...Object.keys(contract.modules).map(read)].some((source) => source.includes(label));
|
||||
if (!found) failures.push(`interaction label ${label} is missing`);
|
||||
}
|
||||
|
||||
requirePattern(
|
||||
style,
|
||||
/@media\s*\(max-width:\s*780px\)[\s\S]*?\.channel-connection-summary article/,
|
||||
'mobile connection-summary rule is missing',
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 admin channels verification failed:\n- ${failures.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 admin channels verified: ${entryLines} stable-entry lines, `
|
||||
+ `${Object.keys(contract.modules).length} focused modules, real API calls and page-scoped CSS preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,110 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const contractPath = path.join(root, 'docs/contracts/admin-enterprise-applications-r11.json');
|
||||
const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
|
||||
function requirePattern(source, pattern, message) {
|
||||
if (!pattern.test(source)) failures.push(message);
|
||||
}
|
||||
|
||||
const entry = read(contract.stableEntry);
|
||||
const entryLines = entry.split(/\r?\n/).length;
|
||||
requirePattern(
|
||||
entry,
|
||||
new RegExp(`export\\s+function\\s+${contract.stableExport}\\s*\\(`),
|
||||
`stable export ${contract.stableExport} is missing`,
|
||||
);
|
||||
if (entryLines > contract.maxStableEntryLines) {
|
||||
failures.push(`stable entry has ${entryLines} lines; maximum is ${contract.maxStableEntryLines}`);
|
||||
}
|
||||
if (!entry.includes(`import '${contract.styleImport}';`)) {
|
||||
failures.push(`stable entry does not import ${contract.styleImport}`);
|
||||
}
|
||||
|
||||
const moduleSources = [];
|
||||
for (const [file, exports] of Object.entries(contract.modules)) {
|
||||
const source = read(file);
|
||||
moduleSources.push(source);
|
||||
for (const exportedName of exports) {
|
||||
requirePattern(
|
||||
source,
|
||||
new RegExp(`export\\s+(?:function|const|class)\\s+${exportedName}\\b`),
|
||||
`${file} is missing export ${exportedName}`,
|
||||
);
|
||||
}
|
||||
if (source.includes('adminApi.')) {
|
||||
failures.push(`${file} must not move real API coordination out of the stable entry`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, methods] of Object.entries(contract.apiCalls)) {
|
||||
const source = read(file);
|
||||
for (const method of methods) {
|
||||
requirePattern(source, new RegExp(`adminApi\\.${method}\\b`), `${file} is missing adminApi.${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const style = read(contract.styleFile);
|
||||
const globalStyle = read('src/styles/global.css');
|
||||
const adminStyle = read('src/styles/admin.css');
|
||||
const componentStyle = read('src/styles/components.css');
|
||||
const shellStyle = read('src/styles/shell.css');
|
||||
for (const selector of contract.pageStyleSelectors) {
|
||||
if (!style.includes(selector)) failures.push(`${contract.styleFile} is missing ${selector}`);
|
||||
if (globalStyle.includes(selector)) failures.push(`src/styles/global.css still contains page selector ${selector}`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptGlobal) {
|
||||
if (!globalStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from global.css`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptInAdmin) {
|
||||
if (!adminStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from admin.css`);
|
||||
if (globalStyle.includes(selector)) failures.push(`shared selector ${selector} still exists in global.css`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptInComponents) {
|
||||
if (!componentStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from components.css`);
|
||||
if (globalStyle.includes(selector)) failures.push(`shared selector ${selector} still exists in global.css`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptInShell) {
|
||||
if (!shellStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from shell.css`);
|
||||
if (globalStyle.includes(selector)) failures.push(`shared selector ${selector} still exists in global.css`);
|
||||
}
|
||||
for (const label of contract.interactionLabels) {
|
||||
if (![entry, ...moduleSources].some((source) => source.includes(label))) {
|
||||
failures.push(`interaction label ${label} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
requirePattern(
|
||||
style,
|
||||
/@media\s*\(max-width:\s*780px\)[\s\S]*?\.admin-application-filter[\s\S]*?grid-template-columns:\s*1fr/,
|
||||
'780px enterprise application filter responsive rule is missing',
|
||||
);
|
||||
requirePattern(
|
||||
style,
|
||||
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.cmpp-connection-summary[\s\S]*?\.cmpp-connection-card__grid/,
|
||||
'900px CMPP connection responsive rule is missing',
|
||||
);
|
||||
requirePattern(
|
||||
style,
|
||||
/@media\s*\(max-width:\s*520px\)[\s\S]*?\.cmpp-connection-summary[\s\S]*?\.cmpp-connection-card__grid/,
|
||||
'520px CMPP connection responsive rule is missing',
|
||||
);
|
||||
requirePattern(
|
||||
entry,
|
||||
/const \[appliedEnterpriseKeyword,[\s\S]*?const \[appliedApplicationKeyword,[\s\S]*?const \[appliedStatus,/,
|
||||
'draft and applied filter state separation is missing',
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 admin enterprise applications verification failed:\n- ${failures.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 admin enterprise applications verified: ${entryLines} stable-entry lines, `
|
||||
+ `${Object.keys(contract.modules).length} focused modules, real API coordination and page-scoped CSS preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const root = process.cwd();
|
||||
const contract = JSON.parse(fs.readFileSync(path.join(root, 'docs/contracts/admin-shared-styles-r11.json'), 'utf8'));
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
const style = read(contract.styleFile);
|
||||
const globalStyle = read(contract.globalStyleFile);
|
||||
const entry = read(contract.entryFile);
|
||||
const styleTree = postcss.parse(style);
|
||||
const globalTree = postcss.parse(globalStyle);
|
||||
|
||||
const listFiles = (directory, extensions) => {
|
||||
const result = [];
|
||||
const visit = (current) => {
|
||||
for (const item of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const absolute = path.join(current, item.name);
|
||||
if (item.isDirectory()) visit(absolute);
|
||||
else if (extensions.some((extension) => item.name.endsWith(extension))) result.push(absolute);
|
||||
}
|
||||
};
|
||||
visit(path.join(root, directory));
|
||||
return result;
|
||||
};
|
||||
|
||||
const ownedClass = (className) => (
|
||||
contract.ownedExactClasses.includes(className)
|
||||
|| contract.ownedClassPrefixes.some((prefix) => className.startsWith(prefix))
|
||||
);
|
||||
const allowedContextClass = (className) => (
|
||||
ownedClass(className)
|
||||
|| contract.allowedContextClasses.includes(className)
|
||||
|| className.startsWith('ui-')
|
||||
);
|
||||
const selectorClasses = (selector) => [...selector.matchAll(/\.([_a-zA-Z]+[\w-]*)/g)].map((match) => match[1]);
|
||||
const selectorBranches = (rule) => rule.selectors ?? rule.selector.split(',').map((selector) => selector.trim());
|
||||
|
||||
if (!style.includes(contract.marker)) failures.push(`${contract.styleFile} is missing its ownership marker`);
|
||||
|
||||
const importOrder = [
|
||||
"@/styles/tokens.css",
|
||||
"@/styles/reset.css",
|
||||
"@/styles/shell.css",
|
||||
"@/styles/global.css",
|
||||
"@/styles/admin.css",
|
||||
"@/styles/components.css",
|
||||
];
|
||||
let previousIndex = -1;
|
||||
for (const importPath of importOrder) {
|
||||
const index = entry.indexOf(`import '${importPath}';`);
|
||||
if (index < 0) failures.push(`${contract.entryFile} is missing ${importPath}`);
|
||||
if (index <= previousIndex) failures.push(`${contract.entryFile} has an invalid shared style import order at ${importPath}`);
|
||||
previousIndex = index;
|
||||
}
|
||||
if (entry.indexOf("import { AppRoutes }") <= previousIndex) {
|
||||
failures.push('AppRoutes must load after the complete shared style dependency chain');
|
||||
}
|
||||
|
||||
let ruleCount = 0;
|
||||
let selectorCount = 0;
|
||||
styleTree.walkRules((rule) => {
|
||||
if (rule.parent?.type === 'atrule' && rule.parent.name.endsWith('keyframes')) return;
|
||||
ruleCount += 1;
|
||||
for (const selector of selectorBranches(rule)) {
|
||||
selectorCount += 1;
|
||||
const classes = selectorClasses(selector);
|
||||
if (!classes.some(ownedClass)) failures.push(`${contract.styleFile} contains a selector without admin ownership: ${selector}`);
|
||||
if (!classes.every(allowedContextClass)) failures.push(`${contract.styleFile} contains page/client context: ${selector}`);
|
||||
}
|
||||
});
|
||||
if (ruleCount < contract.minimumRuleCount) failures.push(`${contract.styleFile} has ${ruleCount} rules; minimum is ${contract.minimumRuleCount}`);
|
||||
if (selectorCount < contract.minimumSelectorCount) failures.push(`${contract.styleFile} has ${selectorCount} selectors; minimum is ${contract.minimumSelectorCount}`);
|
||||
|
||||
for (const selector of contract.requiredSelectors) {
|
||||
if (!style.includes(selector)) failures.push(`${contract.styleFile} is missing ${selector}`);
|
||||
}
|
||||
|
||||
globalTree.walkRules((rule) => {
|
||||
if (rule.parent?.type === 'atrule' && rule.parent.name.endsWith('keyframes')) return;
|
||||
for (const selector of selectorBranches(rule)) {
|
||||
const classes = selectorClasses(selector);
|
||||
if (!classes.some(ownedClass)) continue;
|
||||
if (classes.every(allowedContextClass)) {
|
||||
failures.push(`${contract.globalStyleFile} still owns a pure admin shared selector: ${selector}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const adminFiles = listFiles('src/apps/admin', ['.ts', '.tsx']);
|
||||
const clientFiles = listFiles('src/apps/client', ['.ts', '.tsx']);
|
||||
for (const [className, minimumFiles] of Object.entries(contract.usageRequirements)) {
|
||||
const count = adminFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(className)).length;
|
||||
if (count < minimumFiles) failures.push(`${className} is used by ${count} admin files; minimum is ${minimumFiles}`);
|
||||
}
|
||||
for (const file of clientFiles) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const className of contract.ownedExactClasses) {
|
||||
if (source.includes(className)) failures.push(`${path.relative(root, file)} depends on admin-only class ${className}`);
|
||||
}
|
||||
for (const prefix of contract.ownedClassPrefixes) {
|
||||
if (source.includes(prefix)) failures.push(`${path.relative(root, file)} depends on admin-only class family ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requirement of contract.responsiveRequirements) {
|
||||
let found = false;
|
||||
styleTree.walkAtRules('media', (atRule) => {
|
||||
if (atRule.params !== requirement.media) return;
|
||||
atRule.walkRules((rule) => {
|
||||
if (selectorBranches(rule).some((selector) => selector.includes(requirement.selector))) found = true;
|
||||
});
|
||||
});
|
||||
if (!found) failures.push(`${contract.styleFile} is missing ${requirement.selector} under @media ${requirement.media}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 admin shared styles verification failed:\n- ${failures.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 admin shared styles verified: ${ruleCount} rules, ${selectorCount} selectors, `
|
||||
+ `${contract.usageRequirements ? Object.keys(contract.usageRequirements).length : 0} cross-page usages and admin-only ownership are isolated.`,
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const contractPath = path.join(root, 'docs/contracts/admin-sms-records-r11.json');
|
||||
const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
|
||||
function requirePattern(source, pattern, message) {
|
||||
if (!pattern.test(source)) failures.push(message);
|
||||
}
|
||||
|
||||
const entry = read(contract.stableEntry);
|
||||
const entryLines = entry.split(/\r?\n/).length;
|
||||
requirePattern(
|
||||
entry,
|
||||
new RegExp(`export\\s+function\\s+${contract.stableExport}\\s*\\(`),
|
||||
`stable export ${contract.stableExport} is missing`,
|
||||
);
|
||||
if (entryLines > contract.maxStableEntryLines) {
|
||||
failures.push(`stable entry has ${entryLines} lines; maximum is ${contract.maxStableEntryLines}`);
|
||||
}
|
||||
if (!entry.includes(`import '${contract.styleImport}';`)) {
|
||||
failures.push(`stable entry does not import ${contract.styleImport}`);
|
||||
}
|
||||
|
||||
for (const [file, exports] of Object.entries(contract.modules)) {
|
||||
const source = read(file);
|
||||
for (const exportedName of exports) {
|
||||
requirePattern(
|
||||
source,
|
||||
new RegExp(`export\\s+(?:function|const|class)\\s+${exportedName}\\b`),
|
||||
`${file} is missing export ${exportedName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, methods] of Object.entries(contract.apiCalls)) {
|
||||
const source = read(file);
|
||||
for (const method of methods) {
|
||||
requirePattern(source, new RegExp(`adminApi\\.${method}\\b`), `${file} is missing adminApi.${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const style = read(contract.styleFile);
|
||||
const globalStyle = read('src/styles/global.css');
|
||||
const componentStyle = read('src/styles/components.css');
|
||||
for (const selector of contract.pageStyleSelectors) {
|
||||
if (!style.includes(selector)) failures.push(`${contract.styleFile} is missing ${selector}`);
|
||||
if (globalStyle.includes(selector)) failures.push(`src/styles/global.css still contains page selector ${selector}`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptGlobal) {
|
||||
if (!globalStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from global.css`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptInComponents) {
|
||||
if (!componentStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from components.css`);
|
||||
if (globalStyle.includes(selector)) failures.push(`shared selector ${selector} still exists in global.css`);
|
||||
}
|
||||
for (const label of contract.interactionLabels) {
|
||||
const found = [entry, ...Object.keys(contract.modules).map(read)].some((source) => source.includes(label));
|
||||
if (!found) failures.push(`interaction label ${label} is missing`);
|
||||
}
|
||||
|
||||
requirePattern(
|
||||
style,
|
||||
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.admin-sms-record-card__meta[\s\S]*?\.admin-sms-segment-card dl/,
|
||||
'900px record/detail responsive rule is missing',
|
||||
);
|
||||
requirePattern(
|
||||
style,
|
||||
/@media\s*\(max-width:\s*780px\)[\s\S]*?\.admin-sms-record-filter[\s\S]*?\.admin-sms-route-list dl/,
|
||||
'780px record/detail responsive rule is missing',
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 admin SMS records verification failed:\n- ${failures.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 admin SMS records verified: ${entryLines} stable-entry lines, `
|
||||
+ `${Object.keys(contract.modules).length} focused modules, real API boundaries and page-scoped CSS preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const contractPath = path.join(root, 'docs/contracts/admin-sms-task-progress-r11.json');
|
||||
const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
|
||||
function requirePattern(source, pattern, message) {
|
||||
if (!pattern.test(source)) failures.push(message);
|
||||
}
|
||||
function startsSelectorBranch(source, selector) {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^\\s*${escaped}(?=[\\s,:>{])`, 'm').test(source);
|
||||
}
|
||||
|
||||
const entry = read(contract.stableEntry);
|
||||
const entryLines = entry.split(/\r?\n/).length;
|
||||
requirePattern(
|
||||
entry,
|
||||
new RegExp(`export\\s+function\\s+${contract.stableExport}\\s*\\(`),
|
||||
`stable export ${contract.stableExport} is missing`,
|
||||
);
|
||||
if (entryLines > contract.maxStableEntryLines) {
|
||||
failures.push(`stable entry has ${entryLines} lines; maximum is ${contract.maxStableEntryLines}`);
|
||||
}
|
||||
if (!entry.includes(`import '${contract.styleImport}';`)) {
|
||||
failures.push(`stable entry does not import ${contract.styleImport}`);
|
||||
}
|
||||
|
||||
for (const [file, exports] of Object.entries(contract.modules)) {
|
||||
const source = read(file);
|
||||
for (const exportedName of exports) {
|
||||
requirePattern(
|
||||
source,
|
||||
new RegExp(`export\\s+(?:function|const|class)\\s+${exportedName}\\b`),
|
||||
`${file} is missing export ${exportedName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, methods] of Object.entries(contract.apiCalls)) {
|
||||
const source = read(file);
|
||||
for (const method of methods) {
|
||||
requirePattern(source, new RegExp(`adminApi\\.${method}\\b`), `${file} is missing adminApi.${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const style = read(contract.styleFile);
|
||||
const globalStyle = read('src/styles/global.css');
|
||||
const adminStyle = read('src/styles/admin.css');
|
||||
for (const selector of contract.pageStyleSelectors) {
|
||||
if (!style.includes(selector)) failures.push(`${contract.styleFile} is missing ${selector}`);
|
||||
if (globalStyle.includes(selector)) failures.push(`src/styles/global.css still contains page selector ${selector}`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptGlobal) {
|
||||
if (!globalStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from global.css`);
|
||||
}
|
||||
for (const selector of contract.sharedSelectorsKeptInAdmin) {
|
||||
if (!adminStyle.includes(selector)) failures.push(`shared selector ${selector} was removed from admin.css`);
|
||||
if (startsSelectorBranch(globalStyle, selector)) failures.push(`shared selector ${selector} still owns a branch in global.css`);
|
||||
}
|
||||
for (const label of contract.interactionLabels) {
|
||||
const found = [entry, ...Object.keys(contract.modules).map(read)].some((source) => source.includes(label));
|
||||
if (!found) failures.push(`interaction label ${label} is missing`);
|
||||
}
|
||||
|
||||
requirePattern(
|
||||
style,
|
||||
/@media\s*\(max-width:\s*780px\)[\s\S]*?\.admin-task-detail-grid[\s\S]*?\.admin-carrier-grid/,
|
||||
'mobile detail-grid rule is missing',
|
||||
);
|
||||
|
||||
const model = read('src/apps/admin/sms-task-progress/taskModel.ts');
|
||||
if (model.includes('adminApi.')) failures.push('pure task model contains an adminApi call');
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 admin SMS task progress verification failed:\n- ${failures.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 admin SMS task progress verified: ${entryLines} stable-entry lines, `
|
||||
+ `${Object.keys(contract.modules).length} focused modules, real API boundaries and page-scoped CSS preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const root = process.cwd();
|
||||
const contract = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'docs/contracts/app-shell-styles-r11.json'), 'utf8'),
|
||||
);
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
|
||||
function normalizeDeclarations(rule) {
|
||||
return rule.nodes
|
||||
.filter((node) => node.type === 'decl')
|
||||
.map((node) => `${node.prop}:${node.value}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function mediaOf(rule) {
|
||||
let parent = rule.parent;
|
||||
while (parent) {
|
||||
if (parent.type === 'atrule' && parent.name === 'media') return parent.params;
|
||||
parent = parent.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entry = read(contract.entry);
|
||||
const shellImport = `import '@/styles/shell.css';`;
|
||||
const afterImport = `import '${contract.importAfter}';`;
|
||||
const beforeImport = `import '${contract.importBefore}';`;
|
||||
const shellImportIndex = entry.indexOf(shellImport);
|
||||
if (shellImportIndex < 0) {
|
||||
failures.push(`${contract.entry} is missing ${shellImport}`);
|
||||
} else if (
|
||||
entry.indexOf(afterImport) < 0
|
||||
|| entry.indexOf(beforeImport) < 0
|
||||
|| entry.indexOf(afterImport) >= shellImportIndex
|
||||
|| shellImportIndex >= entry.indexOf(beforeImport)
|
||||
) {
|
||||
failures.push(`${contract.shellFile} must load after reset and before legacy global styles`);
|
||||
}
|
||||
|
||||
const component = read(contract.component);
|
||||
for (const className of contract.shellClasses) {
|
||||
if (!component.includes(className)) {
|
||||
failures.push(`${contract.component} is missing shell state/class ${className}`);
|
||||
}
|
||||
}
|
||||
|
||||
const shellRoot = postcss.parse(read(contract.shellFile), { from: contract.shellFile });
|
||||
const shellRules = [];
|
||||
shellRoot.walkRules((rule) => shellRules.push(rule));
|
||||
if (shellRules.length < contract.minimumRuleCount) {
|
||||
failures.push(`${contract.shellFile} has ${shellRules.length} rules; expected at least ${contract.minimumRuleCount}`);
|
||||
}
|
||||
|
||||
const mediaQueries = new Set();
|
||||
shellRoot.walkAtRules('media', (atRule) => mediaQueries.add(atRule.params));
|
||||
for (const query of contract.requiredMediaQueries) {
|
||||
if (!mediaQueries.has(query)) failures.push(`${contract.shellFile} is missing @media ${query}`);
|
||||
}
|
||||
|
||||
for (const requirement of contract.requiredRuleFragments) {
|
||||
const matchingRules = shellRules.filter(
|
||||
(rule) => rule.selectors.includes(requirement.selector) && mediaOf(rule) === requirement.media,
|
||||
);
|
||||
if (matchingRules.length !== 1) {
|
||||
failures.push(
|
||||
`${contract.shellFile} expected one ${requirement.selector} rule`
|
||||
+ `${requirement.media ? ` in @media ${requirement.media}` : ' outside media queries'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const declarations = normalizeDeclarations(matchingRules[0]);
|
||||
for (const fragment of requirement.includes) {
|
||||
if (!declarations.includes(fragment)) {
|
||||
failures.push(`${requirement.selector} is missing declaration ${fragment}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyRoots = contract.legacyFiles.map((file) => ({
|
||||
file,
|
||||
root: postcss.parse(read(file), { from: file }),
|
||||
}));
|
||||
for (const className of [...contract.shellClasses, ...contract.legacyShellClasses]) {
|
||||
if (className === 'app-shell--mobile-nav-open') continue;
|
||||
const classPattern = new RegExp(`(^|[^a-zA-Z0-9_-])\\.${className}(?![a-zA-Z0-9_-])`);
|
||||
const owned = shellRules.some((rule) => rule.selectors.some((selector) => classPattern.test(selector)));
|
||||
if (!owned) failures.push(`${contract.shellFile} does not own .${className}`);
|
||||
|
||||
for (const legacy of legacyRoots) {
|
||||
legacy.root.walkRules((rule) => {
|
||||
if (rule.selectors.some((selector) => classPattern.test(selector))) {
|
||||
failures.push(`${legacy.file} still contains shell selector ${rule.selector}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const selector of contract.sharedLayoutSelectors) {
|
||||
const owned = shellRules.some((rule) => rule.selectors.includes(selector) && mediaOf(rule) === undefined);
|
||||
if (!owned) failures.push(`${contract.shellFile} is missing shared layout selector ${selector}`);
|
||||
for (const legacy of legacyRoots) {
|
||||
legacy.root.walkRules((rule) => {
|
||||
if (rule.selectors.includes(selector)) {
|
||||
failures.push(`${legacy.file} still owns shared layout selector ${selector}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 AppShell styles verification failed:\n- ${[...new Set(failures)].join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 AppShell styles verified: ${shellRules.length} rules, `
|
||||
+ `${contract.shellClasses.length + contract.legacyShellClasses.length} shell classes, `
|
||||
+ `${contract.sharedLayoutSelectors.length} shared layout selectors and responsive states are isolated.`,
|
||||
);
|
||||
@@ -0,0 +1,180 @@
|
||||
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.`,
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const root = process.cwd();
|
||||
const contract = JSON.parse(fs.readFileSync(path.join(root, 'docs/contracts/client-shared-styles-r11.json'), 'utf8'));
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
const style = read(contract.styleFile);
|
||||
const globalStyle = read(contract.globalStyleFile);
|
||||
const entry = read(contract.entryFile);
|
||||
const styleTree = postcss.parse(style);
|
||||
|
||||
const listFiles = (directory, extensions) => {
|
||||
const result = [];
|
||||
const visit = (current) => {
|
||||
for (const item of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const absolute = path.join(current, item.name);
|
||||
if (item.isDirectory()) visit(absolute);
|
||||
else if (extensions.some((extension) => item.name.endsWith(extension))) result.push(absolute);
|
||||
}
|
||||
};
|
||||
visit(path.join(root, directory));
|
||||
return result;
|
||||
};
|
||||
|
||||
const clientFiles = listFiles('src/apps/client', ['.ts', '.tsx']);
|
||||
const adminFiles = listFiles('src/apps/admin', ['.ts', '.tsx']);
|
||||
const countUsage = (files, className) => files.filter((file) => fs.readFileSync(file, 'utf8').includes(className)).length;
|
||||
const selectorClasses = (selector) => [...selector.matchAll(/\.([_a-zA-Z]+[\w-]*)/g)].map((match) => match[1]);
|
||||
|
||||
if (!style.includes(contract.marker)) failures.push(`${contract.styleFile} is missing its ownership marker`);
|
||||
|
||||
const importOrder = [
|
||||
"@/styles/tokens.css",
|
||||
"@/styles/reset.css",
|
||||
"@/styles/shell.css",
|
||||
"@/styles/global.css",
|
||||
"@/styles/admin.css",
|
||||
"@/styles/client.css",
|
||||
"@/styles/components.css",
|
||||
];
|
||||
let previousIndex = -1;
|
||||
for (const importPath of importOrder) {
|
||||
const index = entry.indexOf(`import '${importPath}';`);
|
||||
if (index < 0) failures.push(`${contract.entryFile} is missing ${importPath}`);
|
||||
if (index <= previousIndex) failures.push(`${contract.entryFile} has an invalid style import order at ${importPath}`);
|
||||
previousIndex = index;
|
||||
}
|
||||
if (entry.indexOf("import { AppRoutes }") <= previousIndex) {
|
||||
failures.push('AppRoutes must load after the complete shared style dependency chain');
|
||||
}
|
||||
|
||||
let ruleCount = 0;
|
||||
let selectorCount = 0;
|
||||
styleTree.walkRules((rule) => {
|
||||
ruleCount += 1;
|
||||
for (const selector of rule.selectors ?? [rule.selector]) {
|
||||
selectorCount += 1;
|
||||
const classes = selectorClasses(selector);
|
||||
if (classes.length === 0 || !classes.every((className) => contract.ownedClasses.includes(className))) {
|
||||
failures.push(`${contract.styleFile} contains a non-client-shared selector: ${selector}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (ruleCount < contract.minimumRuleCount) failures.push(`${contract.styleFile} has ${ruleCount} rules; minimum is ${contract.minimumRuleCount}`);
|
||||
if (selectorCount < contract.minimumSelectorCount) failures.push(`${contract.styleFile} has ${selectorCount} selectors; minimum is ${contract.minimumSelectorCount}`);
|
||||
|
||||
for (const [className, minimumFiles] of Object.entries(contract.usageRequirements)) {
|
||||
const clientCount = countUsage(clientFiles, className);
|
||||
const adminCount = countUsage(adminFiles, className);
|
||||
if (clientCount < minimumFiles) failures.push(`${className} is used by ${clientCount} client files; minimum is ${minimumFiles}`);
|
||||
if (adminCount !== 0) failures.push(`${className} is unexpectedly used by ${adminCount} admin files`);
|
||||
const pureSelector = new RegExp(`^\\s*\\.${className}\\s*\\{`, 'm');
|
||||
if (pureSelector.test(globalStyle)) failures.push(`${contract.globalStyleFile} still owns .${className}`);
|
||||
}
|
||||
|
||||
for (const selector of contract.requiredGlobalContextSelectors) {
|
||||
if (!globalStyle.includes(selector)) failures.push(`${contract.globalStyleFile} lost contextual override ${selector}`);
|
||||
}
|
||||
|
||||
for (const [className, requirement] of Object.entries(contract.crossPortalClassesKeptGlobal)) {
|
||||
if (!globalStyle.includes(`.${className}`)) failures.push(`${contract.globalStyleFile} lost cross-portal class .${className}`);
|
||||
const clientCount = countUsage(clientFiles, className);
|
||||
const adminCount = countUsage(adminFiles, className);
|
||||
if (clientCount < requirement.minimumClientFiles) failures.push(`${className} client usage dropped to ${clientCount}`);
|
||||
if (adminCount < requirement.minimumAdminFiles) failures.push(`${className} admin usage dropped to ${adminCount}`);
|
||||
}
|
||||
|
||||
for (const className of contract.singlePageClassesKeptGlobal) {
|
||||
if (!globalStyle.includes(`.${className}`)) failures.push(`${contract.globalStyleFile} lost single-page class .${className}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 client shared styles verification failed:\n- ${failures.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 client shared styles verified: ${ruleCount} rule, ${selectorCount} selector, `
|
||||
+ `${Object.keys(contract.crossPortalClassesKeptGlobal).length} cross-portal boundaries and single-page ownership are preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
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/admin-enterprise-signatures-r4.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.TSX),
|
||||
};
|
||||
}
|
||||
|
||||
const componentDirectory = 'src/apps/admin/enterprise-signatures';
|
||||
const loadedFiles = manifest.files.map((file) => ({
|
||||
relativePath: `${componentDirectory}/${file}`,
|
||||
...load(`${componentDirectory}/${file}`),
|
||||
}));
|
||||
const declarations = new Map();
|
||||
for (const loaded of loadedFiles) {
|
||||
for (const statement of loaded.file.statements) {
|
||||
if (!ts.isFunctionDeclaration(statement) || !statement.name) continue;
|
||||
declarations.set(statement.name.text, { statement, file: loaded.file, relativePath: loaded.relativePath });
|
||||
}
|
||||
}
|
||||
for (const expected of manifest.movedFunctions) {
|
||||
const actual = declarations.get(expected.name);
|
||||
if (!actual) throw new Error(`R4 frontend function missing: ${expected.name}`);
|
||||
const canonical = printer
|
||||
.printNode(ts.EmitHint.Unspecified, actual.statement, actual.file)
|
||||
.replace(/^export\s+/, '');
|
||||
if (sha256(canonical) !== expected.canonicalSha256) {
|
||||
throw new Error(`${expected.name} changed while moving out of the page`);
|
||||
}
|
||||
}
|
||||
|
||||
const table = declarations.get('EnterpriseSignaturesTable');
|
||||
if (!table?.statement.body) throw new Error('EnterpriseSignaturesTable not found');
|
||||
const tableReturn = table.statement.body.statements.find(ts.isReturnStatement);
|
||||
if (!tableReturn?.expression) throw new Error('EnterpriseSignaturesTable return expression not found');
|
||||
if (
|
||||
sha256(printer.printNode(ts.EmitHint.Unspecified, tableReturn.expression, table.file)) !==
|
||||
manifest.tableJsxSha256
|
||||
) {
|
||||
throw new Error('Enterprise signatures table JSX changed during R4 extraction');
|
||||
}
|
||||
|
||||
const page = load('src/apps/admin/AdminEnterpriseSignaturesPage.tsx');
|
||||
const pageFunction = page.file.statements.find(
|
||||
(statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === 'AdminEnterpriseSignaturesPage',
|
||||
);
|
||||
if (!pageFunction || !ts.isFunctionDeclaration(pageFunction) || !pageFunction.body) {
|
||||
throw new Error('AdminEnterpriseSignaturesPage container not found');
|
||||
}
|
||||
const pageText = pageFunction.getText(page.file);
|
||||
for (const apiCall of manifest.apiCalls) {
|
||||
if (!pageText.includes(`adminApi.${apiCall}`)) {
|
||||
throw new Error(`R4 page container API coordination missing: ${apiCall}`);
|
||||
}
|
||||
}
|
||||
for (const stateName of manifest.stateNames) {
|
||||
if (!new RegExp(`\\b${stateName}\\b`).test(pageText)) {
|
||||
throw new Error(`R4 page container state missing: ${stateName}`);
|
||||
}
|
||||
}
|
||||
if (!pageText.includes('<EnterpriseSignaturesTable')) {
|
||||
throw new Error('AdminEnterpriseSignaturesPage does not delegate table rendering');
|
||||
}
|
||||
if (page.source.split(/\r?\n/).length >= manifest.originalLines) {
|
||||
throw new Error('AdminEnterpriseSignaturesPage was not reduced by R4 extraction');
|
||||
}
|
||||
for (const loaded of loadedFiles) {
|
||||
if (loaded.source.split(/\r?\n/).length > 360) {
|
||||
throw new Error(`${loaded.relativePath} exceeds the R4 component size boundary`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R4 enterprise-signatures page verified: ${manifest.movedFunctions.length} functions, table JSX, ${manifest.apiCalls.length} real API calls and ${manifest.stateNames.length} page states preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const contract = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'docs/contracts/foundation-styles-r11.json'), 'utf8'),
|
||||
);
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
|
||||
function normalizeSelector(selector) {
|
||||
return selector.replace(/\s+/g, '').trim();
|
||||
}
|
||||
|
||||
function normalizeDeclarations(declarations) {
|
||||
return declarations
|
||||
.split(';')
|
||||
.map((declaration) => declaration.trim())
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function extractFlatRules(source) {
|
||||
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
return [...withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((match) => ({
|
||||
selector: normalizeSelector(match[1]),
|
||||
declarations: normalizeDeclarations(match[2]),
|
||||
}));
|
||||
}
|
||||
|
||||
const entry = read(contract.entry);
|
||||
let previousImportIndex = -1;
|
||||
for (const importPath of contract.importOrder) {
|
||||
const statement = `import '${importPath}';`;
|
||||
const importIndex = entry.indexOf(statement);
|
||||
if (importIndex < 0) {
|
||||
failures.push(`${contract.entry} is missing ${statement}`);
|
||||
} else if (importIndex <= previousImportIndex) {
|
||||
failures.push(`${importPath} is not loaded in the required foundation-to-component order`);
|
||||
}
|
||||
previousImportIndex = importIndex;
|
||||
}
|
||||
const applicationImportIndex = entry.indexOf(contract.applicationImport);
|
||||
if (applicationImportIndex < 0) {
|
||||
failures.push(`${contract.entry} is missing the application route import`);
|
||||
} else if (applicationImportIndex <= previousImportIndex) {
|
||||
failures.push('AppRoutes must enter the dependency graph after the ordered foundation/component styles');
|
||||
}
|
||||
|
||||
const tokens = read(contract.tokensFile);
|
||||
const tokenNames = [...tokens.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].map((match) => match[1]);
|
||||
if (tokenNames.length < contract.minimumTokenCount) {
|
||||
failures.push(`${contract.tokensFile} has ${tokenNames.length} tokens; expected at least ${contract.minimumTokenCount}`);
|
||||
}
|
||||
for (const token of contract.requiredTokens) {
|
||||
if (!tokenNames.includes(token)) failures.push(`${contract.tokensFile} is missing ${token}`);
|
||||
}
|
||||
const tokenRules = extractFlatRules(tokens);
|
||||
if (tokenRules.length !== 1 || tokenRules[0].selector !== ':root') {
|
||||
failures.push(`${contract.tokensFile} must remain a single :root design-token scope`);
|
||||
}
|
||||
|
||||
const reset = read(contract.resetFile);
|
||||
const resetRules = extractFlatRules(reset);
|
||||
const expectedSelectors = Object.keys(contract.resetRuleHashes);
|
||||
const actualSelectors = resetRules.map((rule) => rule.selector);
|
||||
for (const selector of expectedSelectors) {
|
||||
const matches = resetRules.filter((rule) => rule.selector === selector);
|
||||
if (matches.length !== 1) {
|
||||
failures.push(`${contract.resetFile} must contain exactly one ${selector} rule`);
|
||||
continue;
|
||||
}
|
||||
const actualHash = crypto.createHash('sha256').update(matches[0].declarations).digest('hex');
|
||||
if (actualHash !== contract.resetRuleHashes[selector]) {
|
||||
failures.push(`${contract.resetFile} changed declarations for ${selector}`);
|
||||
}
|
||||
}
|
||||
for (const selector of actualSelectors) {
|
||||
if (!expectedSelectors.includes(selector)) {
|
||||
failures.push(`${contract.resetFile} contains non-foundation selector ${selector}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceRules = extractFlatRules(read(contract.sourceFile));
|
||||
const remainingSelectors = new Set(sourceRules.map((rule) => rule.selector));
|
||||
for (const selector of expectedSelectors) {
|
||||
if (remainingSelectors.has(selector)) {
|
||||
failures.push(`${contract.sourceFile} still owns reset selector ${selector}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 foundation styles verification failed:\n- ${failures.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 foundation styles verified: ${tokenNames.length} design tokens, `
|
||||
+ `${resetRules.length} reset/base rules and deterministic import order preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type declaration struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Version string `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Declarations []declaration `json:"declarations"`
|
||||
}
|
||||
|
||||
var requiredFiles = []string{
|
||||
"server.go",
|
||||
"authentication.go",
|
||||
"submit.go",
|
||||
"sessions.go",
|
||||
"delivery.go",
|
||||
"acknowledgement.go",
|
||||
"pending_recovery.go",
|
||||
"protocol_log.go",
|
||||
"transport.go",
|
||||
}
|
||||
|
||||
var requiredTests = []string{
|
||||
"TestInboundServerAuthenticatesAndSubmits",
|
||||
"TestInboundServerForwardsLongMessageFragmentsWithoutUDHAndAcknowledgesEachSubmit",
|
||||
"TestSubmitResponsePrecedesQueuedFailureReceipt",
|
||||
"TestDailyLimitRejectsSubmitSynchronouslyWithoutPendingReceipt",
|
||||
"TestInboundServerNegotiatesCMPP2AndUsesAuthenticatedAccountForSubmit",
|
||||
"TestNormalizeInboundSubmitSupportsCMPP2AndCMPP3",
|
||||
"TestDownstreamDeliveryRequiresAcknowledgement",
|
||||
"TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists",
|
||||
"TestDownstreamDeliveryReportsAckTimeout",
|
||||
"TestRecoverPendingCandidatesWritesWaitingConnectionStatus",
|
||||
"TestSubmitResponseProtocolLoggerEmitsActualPacketDirection",
|
||||
"TestDownstreamDeliverProtocolLoggerEmitsReceiptPacket",
|
||||
}
|
||||
|
||||
func main() {
|
||||
root, err := os.Getwd()
|
||||
must(err)
|
||||
manifestPath := filepath.Join(root, "docs", "contracts", "inbound-r6-declarations.json")
|
||||
payload, err := os.ReadFile(manifestPath)
|
||||
must(err)
|
||||
var contract manifest
|
||||
must(json.Unmarshal(payload, &contract))
|
||||
if contract.Version != "R6" {
|
||||
fail("unexpected contract version %q", contract.Version)
|
||||
}
|
||||
|
||||
inboundDir := filepath.Join(root, "gateway", "internal", "inbound")
|
||||
actual := map[string]declaration{}
|
||||
for _, name := range requiredFiles {
|
||||
path := filepath.Join(inboundDir, name)
|
||||
source, err := os.ReadFile(path)
|
||||
must(err)
|
||||
if !bytes.HasPrefix(source, []byte("package inbound")) {
|
||||
fail("%s must remain in package inbound", name)
|
||||
}
|
||||
for _, item := range declarationsInFile(path, source) {
|
||||
key := item.Kind + ":" + item.Name
|
||||
if previous, exists := actual[key]; exists {
|
||||
fail("duplicate declaration %s in %s and %s", key, previous.File, item.File)
|
||||
}
|
||||
actual[key] = item
|
||||
}
|
||||
}
|
||||
|
||||
for _, expected := range contract.Declarations {
|
||||
key := expected.Kind + ":" + expected.Name
|
||||
item, ok := actual[key]
|
||||
if !ok {
|
||||
fail("missing declaration %s expected in %s", key, expected.File)
|
||||
}
|
||||
if item.File != expected.File {
|
||||
fail("%s moved to %s, expected %s", key, item.File, expected.File)
|
||||
}
|
||||
if item.SHA256 != expected.SHA256 {
|
||||
fail("%s implementation changed: %s != %s", key, item.SHA256, expected.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
serverSource, err := os.ReadFile(filepath.Join(inboundDir, "server.go"))
|
||||
must(err)
|
||||
if lines := bytes.Count(serverSource, []byte{'\n'}); lines > 100 {
|
||||
fail("stable server.go grew to %d lines", lines)
|
||||
}
|
||||
assertContains(serverSource, "func (s Server) ListenAndServe() error")
|
||||
assertContains(serverSource, "s.handleLogin")
|
||||
assertContains(serverSource, "s.handleSubmit")
|
||||
assertContains(serverSource, "s.handleActivity")
|
||||
assertContains(serverSource, "s.handleConnectionClosed")
|
||||
|
||||
controlSource, err := os.ReadFile(filepath.Join(root, "gateway", "internal", "control", "server.go"))
|
||||
must(err)
|
||||
for _, entry := range []string{
|
||||
"inbound.DisconnectAccount(",
|
||||
"inbound.PushReceiptWithResult(",
|
||||
"inbound.PushUplinkWithResult(",
|
||||
} {
|
||||
assertContains(controlSource, entry)
|
||||
}
|
||||
|
||||
var tests bytes.Buffer
|
||||
for _, name := range []string{"server_test.go", "protocol_log_test.go", "presence_test.go", "recovery_test.go"} {
|
||||
source, err := os.ReadFile(filepath.Join(inboundDir, name))
|
||||
must(err)
|
||||
tests.Write(source)
|
||||
}
|
||||
for _, test := range requiredTests {
|
||||
assertContains(tests.Bytes(), "func "+test+"(")
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"R6 inbound facade verified: %d declarations across %d focused files; stable entries and %d critical tests preserved.\n",
|
||||
len(contract.Declarations), len(requiredFiles), len(requiredTests),
|
||||
)
|
||||
}
|
||||
|
||||
func declarationsInFile(path string, source []byte) []declaration {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, source, parser.ParseComments)
|
||||
must(err)
|
||||
var result []declaration
|
||||
for _, decl := range file.Decls {
|
||||
if gen, ok := decl.(*ast.GenDecl); ok && gen.Tok == token.IMPORT {
|
||||
continue
|
||||
}
|
||||
name, kind := declarationName(decl)
|
||||
start := decl.Pos()
|
||||
switch typed := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if typed.Doc != nil {
|
||||
start = typed.Doc.Pos()
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
if typed.Doc != nil {
|
||||
start = typed.Doc.Pos()
|
||||
}
|
||||
}
|
||||
code := strings.TrimSpace(string(source[fset.Position(start).Offset:fset.Position(decl.End()).Offset]))
|
||||
hash := sha256.Sum256([]byte(strings.ReplaceAll(code, "\r\n", "\n")))
|
||||
result = append(result, declaration{
|
||||
Name: name, Kind: kind, File: filepath.Base(path), SHA256: hex.EncodeToString(hash[:]),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func declarationName(decl ast.Decl) (string, string) {
|
||||
switch typed := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
return typed.Name.Name, "func"
|
||||
case *ast.GenDecl:
|
||||
if len(typed.Specs) == 0 {
|
||||
fail("empty declaration")
|
||||
}
|
||||
switch spec := typed.Specs[0].(type) {
|
||||
case *ast.TypeSpec:
|
||||
return spec.Name.Name, "type"
|
||||
case *ast.ValueSpec:
|
||||
return spec.Names[0].Name, strings.ToLower(typed.Tok.String())
|
||||
}
|
||||
}
|
||||
fail("unsupported declaration %T", decl)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func assertContains(source []byte, fragment string) {
|
||||
if !bytes.Contains(source, []byte(fragment)) {
|
||||
fail("required invariant not found: %s", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "R6 verification failed: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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.`);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const manifestPath = resolve(root, 'docs/contracts/refactoring-r0-manifest.json');
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
const absolutePath = resolve(root, relativePath);
|
||||
assert(existsSync(absolutePath), `R0 baseline file is missing: ${relativePath}`);
|
||||
return readFileSync(absolutePath, 'utf8');
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(read('docs/contracts/refactoring-r0-manifest.json'));
|
||||
assert(manifest.version === 'r0', 'R0 manifest version must be r0');
|
||||
assert(/^[0-9a-f]{40}$/.test(manifest.baselineCommit), 'R0 baselineCommit must be a full Git commit');
|
||||
|
||||
for (const contract of manifest.contracts) {
|
||||
for (const path of [contract.schema, contract.validator, ...(contract.examples ?? []), ...(contract.tests ?? [])].filter(Boolean)) {
|
||||
read(path);
|
||||
}
|
||||
assert((contract.invariants?.length ?? contract.examples?.length ?? 0) > 0, `${contract.kind}: no frozen examples or invariants`);
|
||||
}
|
||||
|
||||
const facadeChecks = [
|
||||
['api/src/send-chain/send-chain.service.ts', 'export class SendChainService'],
|
||||
['api/src/channels/channels.service.ts', 'export class ChannelsService'],
|
||||
['api/src/operations/operations.service.ts', 'export class OperationsService'],
|
||||
['api/src/sms-config/sms-config.service.ts', 'export class SmsConfigService'],
|
||||
['api/src/report-materials/report-materials.service.ts', 'export class ReportMaterialsService'],
|
||||
['src/api/adminApi.ts', 'export const adminApi'],
|
||||
['gateway/internal/inbound/server.go', 'type Server struct'],
|
||||
['gateway/internal/upstream/manager.go', 'type Manager struct'],
|
||||
];
|
||||
|
||||
for (const [path, stableExport] of facadeChecks) {
|
||||
assert(read(path).includes(stableExport), `${path}: stable facade not found: ${stableExport}`);
|
||||
}
|
||||
|
||||
const characterizationChecks = [
|
||||
['api/src/send-chain/send-chain.service.spec.ts', 'allows only one retry submit when three long-message failure receipts race'],
|
||||
['api/src/send-chain/send-chain.service.spec.ts', 'creates and sends only one downstream final receipt under concurrent completion'],
|
||||
['api/src/send-chain/send-chain.service.spec.ts', 'atomically claims a downstream manual requeue so concurrent requests only call Gateway once'],
|
||||
['api/src/billing/billing.service.spec.ts', 'serializes and replays concurrent refunds with one balance mutation'],
|
||||
['gateway/internal/inbound/server_test.go', 'TestDownstreamDeliveryRequiresAcknowledgement'],
|
||||
['gateway/internal/upstream/connection_loss_test.go', 'TestReconnectDelayUsesCappedBackoffAndSlowAuthenticationRetry'],
|
||||
['gateway/internal/upstream/reconnect_integration_test.go', 'TestFailedSupplierConnectionReconnectsWhenEndpointRecovers'],
|
||||
['gateway/internal/cmpp/gocmpp_integration_test.go', 'TestGocmppDeliverReceiptPackAndUnpack'],
|
||||
];
|
||||
|
||||
for (const [path, behavior] of characterizationChecks) {
|
||||
assert(read(path).includes(behavior), `${path}: required characterization test is missing: ${behavior}`);
|
||||
}
|
||||
|
||||
read('docs/refactoring/r0-responsibility-index.md');
|
||||
read('docs/refactoring/r0-release-gate.md');
|
||||
|
||||
console.log(`R0 refactoring baseline verified: ${manifest.contracts.length} contract groups, ${facadeChecks.length} stable facades, ${characterizationChecks.length} critical behaviors.`);
|
||||
@@ -0,0 +1,163 @@
|
||||
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/report-materials-r4-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');
|
||||
return {
|
||||
source,
|
||||
file: ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
||||
};
|
||||
}
|
||||
|
||||
function loadClass(relativePath, className) {
|
||||
const loaded = loadSource(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,
|
||||
methods: new Map(
|
||||
declaration.members.filter(ts.isMethodDeclaration).map((method) => [
|
||||
method.name.getText(loaded.file),
|
||||
method,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const reverseReplacements = {
|
||||
officialExport: {
|
||||
'this.pending.findPendingItems': 'this.findPendingItems',
|
||||
},
|
||||
importReview: {
|
||||
'this.importParser.saveImportProfile': 'this.saveImportProfile',
|
||||
},
|
||||
batchGeneration: {
|
||||
'this.operations.claimBatchOperation': 'this.claimBatchOperation',
|
||||
'this.operations.completeBatchOperation': 'this.completeBatchOperation',
|
||||
'this.operations.failBatchOperation': 'this.failBatchOperation',
|
||||
'this.channelExport.exportChannelBatch': 'this.exportChannelBatch',
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
printed = printed
|
||||
.split("ReportBatchGenerationService['preflightBatch']")
|
||||
.join("ReportMaterialsService['preflightBatch']")
|
||||
.split("ReportBatchGenerationService['prepareBatchItem']")
|
||||
.join("ReportMaterialsService['prepareBatchItem']");
|
||||
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?.body) throw new Error(`Missing R4 domain method: ${domain}.${name}`);
|
||||
if (domainMethods.has(name)) throw new Error(`Duplicate R4 domain method: ${name}`);
|
||||
domainMethods.set(name, { domain, method, file: loaded.file });
|
||||
}
|
||||
if (loaded.methods.size !== definition.methods.length) {
|
||||
throw new Error(`Unexpected method found in R4 domain: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedMethods = [...manifest.publicMethods, ...manifest.internalMethods];
|
||||
for (const expected of expectedMethods) {
|
||||
const actual = domainMethods.get(expected.name);
|
||||
if (!actual) throw new Error(`R4 implementation missing: ${expected.name}`);
|
||||
if (actual.domain !== expected.domain) throw new Error(`${expected.name} moved to unexpected R4 domain`);
|
||||
if (sha256(normalizedBody(actual.domain, actual.method, actual.file)) !== expected.canonicalBodySha256) {
|
||||
throw new Error(`${expected.name} implementation changed during R4 split`);
|
||||
}
|
||||
}
|
||||
if (domainMethods.size !== expectedMethods.length) {
|
||||
throw new Error('Unexpected R4 implementation method found');
|
||||
}
|
||||
|
||||
const facade = loadClass('api/src/report-materials/report-materials.service.ts', 'ReportMaterialsService');
|
||||
for (const expected of manifest.publicMethods) {
|
||||
const method = facade.methods.get(expected.name);
|
||||
if (!method?.body) throw new Error(`ReportMaterialsService 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 (facade.methods.size !== manifest.publicMethods.length) {
|
||||
throw new Error('Unexpected ReportMaterialsService facade method found');
|
||||
}
|
||||
|
||||
const contracts = loadSource('api/src/report-materials/report-materials.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).replace(/^export\s+/, '')),
|
||||
]),
|
||||
);
|
||||
for (const expected of manifest.contracts) {
|
||||
if (actualContracts.get(expected.name) !== expected.sha256) {
|
||||
throw new Error(`Report-materials contract changed during R4 split: ${expected.name}`);
|
||||
}
|
||||
}
|
||||
if (actualContracts.size !== manifest.contracts.length) {
|
||||
throw new Error('Unexpected report-materials contract found');
|
||||
}
|
||||
|
||||
const helpers = loadSource('api/src/report-materials/report-materials.helpers.ts');
|
||||
const actualHelpers = new Map();
|
||||
for (const statement of helpers.file.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
|
||||
if (statement.name) {
|
||||
actualHelpers.set(statement.name.text, sha256(statement.getText(helpers.file).replace(/^export\s+/, '')));
|
||||
}
|
||||
} else if (ts.isVariableStatement(statement)) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
actualHelpers.set(
|
||||
declaration.name.getText(helpers.file),
|
||||
sha256(statement.getText(helpers.file).replace(/^export\s+/, '')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const expected of manifest.helpers) {
|
||||
if (actualHelpers.get(expected.name) !== expected.sha256) {
|
||||
throw new Error(`Report-materials helper changed during R4 split: ${expected.name}`);
|
||||
}
|
||||
}
|
||||
if (actualHelpers.size !== manifest.helpers.length) {
|
||||
throw new Error('Unexpected report-materials helper found');
|
||||
}
|
||||
|
||||
const controller = loadSource('api/src/report-materials/report-materials.controller.ts');
|
||||
for (const statement of controller.file.statements.filter(ts.isImportDeclaration)) {
|
||||
if (!statement.moduleSpecifier.getText(controller.file).includes('report-materials.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('Mapping'))) {
|
||||
throw new Error('ReportMaterialsController still imports contracts from the implementation facade');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R4 report-materials facade verified: ${manifest.publicMethods.length} public methods, ${manifest.internalMethods.length} internal methods, ${manifest.contracts.length} contracts and ${manifest.helpers.length} helpers; implementations unchanged.`,
|
||||
);
|
||||
@@ -0,0 +1,110 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const root = process.cwd();
|
||||
const contract = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'docs/contracts/send-chain-r10-completion.json'), 'utf8'),
|
||||
);
|
||||
const parse = (filePath, source) =>
|
||||
ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const methodsByName = (classNode) => new Map(
|
||||
classNode.members
|
||||
.filter((member) => ts.isMethodDeclaration(member) && member.name && ts.isIdentifier(member.name))
|
||||
.map((member) => [member.name.text, member]),
|
||||
);
|
||||
const digest = (value) =>
|
||||
crypto.createHash('sha256').update(value.replace(/\r\n/g, '\n')).digest('hex');
|
||||
const normalizeFacadeSeams = (value) => value.replaceAll('this.facade.', 'this.');
|
||||
|
||||
const facadePath = path.join(root, 'api/src/send-chain/send-chain.service.ts');
|
||||
const facadeSource = fs.readFileSync(facadePath, 'utf8');
|
||||
const facadeFile = parse(facadePath, facadeSource);
|
||||
const facadeClass = facadeFile.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === 'SendChainService',
|
||||
);
|
||||
const completionPath = path.join(root, 'api/src/send-chain/send-completion.service.ts');
|
||||
const completionSource = fs.readFileSync(completionPath, 'utf8');
|
||||
const completionFile = parse(completionPath, completionSource);
|
||||
const completionClass = completionFile.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === 'SendCompletionService',
|
||||
);
|
||||
if (!facadeClass || !completionClass) throw new Error('R10 facade is missing');
|
||||
const facadeMethods = methodsByName(facadeClass);
|
||||
const completionMethods = methodsByName(completionClass);
|
||||
|
||||
const domainSources = new Map();
|
||||
const domainMethods = new Map();
|
||||
for (const domain of contract.domains) {
|
||||
const filePath = path.join(root, 'api/src/send-chain', domain.file);
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const file = parse(filePath, source);
|
||||
const classNode = file.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === domain.className,
|
||||
);
|
||||
if (!classNode) throw new Error(`R10 domain class missing: ${domain.className}`);
|
||||
const methods = methodsByName(classNode);
|
||||
const expectedCount = contract.methods.filter((method) => method.file === domain.file).length;
|
||||
if (expectedCount !== domain.methodCount) throw new Error(`R10 domain count changed: ${domain.file}`);
|
||||
domainSources.set(domain.file, { source, file });
|
||||
domainMethods.set(domain.file, methods);
|
||||
}
|
||||
|
||||
for (const expected of contract.methods) {
|
||||
const domain = domainSources.get(expected.file);
|
||||
const migrated = domainMethods.get(expected.file)?.get(expected.name);
|
||||
if (!domain || !migrated?.body) throw new Error(`R10 migrated method missing: ${expected.name}`);
|
||||
if (digest(normalizeFacadeSeams(migrated.body.getText(domain.file))) !== expected.bodySha256) {
|
||||
throw new Error(`R10 migrated method body changed: ${expected.name}`);
|
||||
}
|
||||
const completion = completionMethods.get(expected.name);
|
||||
if (!completion?.body || !completion.body.getText(completionFile).includes(`.${expected.name}(`)) {
|
||||
throw new Error(`R10 completion facade does not delegate: ${expected.name}`);
|
||||
}
|
||||
const facade = facadeMethods.get(expected.name);
|
||||
if (!facade?.body || !facade.body.getText(facadeFile).includes(`this.completion.${expected.name}(`)) {
|
||||
throw new Error(`R10 stable facade does not delegate: ${expected.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (facadeMethods.size !== 98) {
|
||||
throw new Error(`R10 changed the stable SendChainService method count: ${facadeMethods.size}`);
|
||||
}
|
||||
|
||||
const combined = [...domainSources.values()].map((item) => item.source).join('\n');
|
||||
const gatewaySubmitSource = fs.readFileSync(
|
||||
path.join(root, 'api/src/send-chain/send-gateway-submit.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
for (const invariant of [
|
||||
'retryOfSubmitRecordId: sourceAttempt.id',
|
||||
'where: { retryOfSubmitRecordId }',
|
||||
"error.code === 'P2002'",
|
||||
'isCurrentAttempt',
|
||||
'sms-reservation-release:${message.messageId}',
|
||||
'sms-refund:${message.messageId}',
|
||||
'receiptEventKey(data, logicalChannelId)',
|
||||
"dedupeKey = data.deliveryType === 'receipt'",
|
||||
"status: 'manual_requeueing'",
|
||||
'gatewaySubmitRequeueKey',
|
||||
'recordReceiptSegment',
|
||||
'aggregateReceiptSegments',
|
||||
]) {
|
||||
if (!(combined + gatewaySubmitSource).includes(invariant)) {
|
||||
throw new Error(`R10 accident invariant missing: ${invariant}`);
|
||||
}
|
||||
}
|
||||
if (combined.includes('deleteMany(')) {
|
||||
throw new Error('R10 must not delete receipt, retry, accounting or downstream history');
|
||||
}
|
||||
|
||||
const moduleSource = fs.readFileSync(path.join(root, 'api/src/send-chain/send-chain.module.ts'), 'utf8');
|
||||
if (!moduleSource.includes('providers: [SendChainService]') || moduleSource.includes('SendCompletionService,')) {
|
||||
throw new Error('R10 changed the public NestJS provider boundary');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R10 send completion verified: ${contract.methods.length} methods across ${contract.domains.length} domains; `
|
||||
+ '98 stable facade methods and accident invariants preserved.',
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import ts from 'typescript';
|
||||
|
||||
const root = process.cwd();
|
||||
const contract = JSON.parse(fs.readFileSync(path.join(root, 'docs', 'contracts', 'send-chain-r8-pure-logic.json'), 'utf8'));
|
||||
const servicePath = path.join(root, 'api', 'src', 'send-chain', 'send-chain.service.ts');
|
||||
const contractsPath = path.join(root, 'api', 'src', 'send-chain', 'send-chain.contracts.ts');
|
||||
const helpersPath = path.join(root, 'api', 'src', 'send-chain', 'send-chain.helpers.ts');
|
||||
const service = fs.readFileSync(servicePath, 'utf8');
|
||||
const contracts = fs.readFileSync(contractsPath, 'utf8');
|
||||
const helpers = fs.readFileSync(helpersPath, 'utf8');
|
||||
const implementationSources = [
|
||||
service,
|
||||
...fs.readdirSync(path.dirname(servicePath))
|
||||
.filter((fileName) => /^send-.*\.service\.ts$/.test(fileName) && fileName !== 'send-chain.service.ts')
|
||||
.map((fileName) => fs.readFileSync(path.join(path.dirname(servicePath), fileName), 'utf8')),
|
||||
].join('\n');
|
||||
|
||||
const parse = (filePath, source) => ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const contractFile = parse(contractsPath, contracts);
|
||||
const helperFile = parse(helpersPath, helpers);
|
||||
const serviceFile = parse(servicePath, service);
|
||||
const digest = (text) => crypto.createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex');
|
||||
const normalizedText = (source, file, node) => source.slice(node.getStart(file), node.end).trim().replace(/^export\s+/, '');
|
||||
|
||||
const contractDeclarations = new Map();
|
||||
for (const statement of contractFile.statements) {
|
||||
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
|
||||
contractDeclarations.set(statement.name.text, statement);
|
||||
}
|
||||
}
|
||||
for (const expected of contract.contracts) {
|
||||
const node = contractDeclarations.get(expected.name);
|
||||
if (!node) throw new Error(`R8 contract missing: ${expected.name}`);
|
||||
const actualHash = digest(normalizedText(contracts, contractFile, node));
|
||||
if (actualHash !== expected.sha256) {
|
||||
throw new Error(`R8 contract changed: ${expected.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const helperDeclarations = new Map();
|
||||
for (const statement of helperFile.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name) {
|
||||
helperDeclarations.set(statement.name.text, statement);
|
||||
} else if (ts.isVariableStatement(statement)) {
|
||||
const name = statement.declarationList.declarations[0]?.name;
|
||||
if (name && ts.isIdentifier(name)) helperDeclarations.set(name.text, statement);
|
||||
}
|
||||
}
|
||||
for (const expected of contract.helpers) {
|
||||
const node = helperDeclarations.get(expected.name);
|
||||
if (!node) throw new Error(`R8 helper missing: ${expected.name}`);
|
||||
const actualHash = digest(normalizedText(helpers, helperFile, node));
|
||||
if (actualHash !== expected.sha256) {
|
||||
throw new Error(`R8 helper changed: ${expected.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const serviceClass = serviceFile.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === 'SendChainService',
|
||||
);
|
||||
if (!serviceClass) throw new Error('R8 stable SendChainService missing');
|
||||
const serviceMethods = serviceClass.members.filter(ts.isMethodDeclaration);
|
||||
if (serviceMethods.length < 60) throw new Error(`R8 service lost orchestration methods: ${serviceMethods.length}`);
|
||||
|
||||
for (const forbidden of serviceFile.statements) {
|
||||
if (ts.isFunctionDeclaration(forbidden) || ts.isInterfaceDeclaration(forbidden) || ts.isTypeAliasDeclaration(forbidden)) {
|
||||
throw new Error('R8 service still contains movable top-level declarations');
|
||||
}
|
||||
}
|
||||
|
||||
for (const invariant of [
|
||||
'this.prisma.$transaction',
|
||||
'publishGatewaySubmitCommand',
|
||||
'getSendQueue()',
|
||||
'getGatewayQueue()',
|
||||
'retryMessageIfAllowed',
|
||||
'recordSubmitSegments',
|
||||
'recordReceiptSegment',
|
||||
]) {
|
||||
if (!implementationSources.includes(invariant)) throw new Error(`R8 side-effect invariant missing: ${invariant}`);
|
||||
}
|
||||
for (const helper of [
|
||||
'selectChannelCandidate',
|
||||
'aggregateReceiptSegmentState',
|
||||
'isSameUpstreamEndpointIdentity',
|
||||
'receiptEventKey',
|
||||
]) {
|
||||
if (!helperDeclarations.has(helper)) throw new Error(`R8 extracted policy missing: ${helper}`);
|
||||
}
|
||||
|
||||
for (const controller of [
|
||||
'admin-send-chain.controller.ts',
|
||||
'client-send-chain.controller.ts',
|
||||
'gateway-events.controller.ts',
|
||||
]) {
|
||||
const source = fs.readFileSync(path.join(root, 'api', 'src', 'send-chain', controller), 'utf8');
|
||||
if (!source.includes("from './send-chain.contracts'")) {
|
||||
throw new Error(`R8 controller does not use contracts: ${controller}`);
|
||||
}
|
||||
}
|
||||
|
||||
const spec = fs.readFileSync(path.join(root, 'api', 'src', 'send-chain', 'send-chain.helpers.spec.ts'), 'utf8');
|
||||
for (const phrase of ['prefers an approved online province channel', 'keeps a segmented message non-terminal', 'generates a stable receipt event key']) {
|
||||
if (!spec.includes(phrase)) throw new Error(`R8 pure policy test missing: ${phrase}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R8 send-chain pure logic verified: ${contract.contracts.length} contracts, ${contract.helpers.length} migrated declarations, `
|
||||
+ `${serviceMethods.length} orchestration methods and 4 extracted policies preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const root = process.cwd();
|
||||
const servicePath = path.join(root, 'api/src/send-chain/send-chain.service.ts');
|
||||
const submissionPath = path.join(root, 'api/src/send-chain/send-submission.service.ts');
|
||||
const contract = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'docs/contracts/send-chain-r9-submission.json'), 'utf8'),
|
||||
);
|
||||
const serviceSource = fs.readFileSync(servicePath, 'utf8');
|
||||
const submissionSource = fs.readFileSync(submissionPath, 'utf8');
|
||||
const parse = (filePath, source) =>
|
||||
ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const serviceFile = parse(servicePath, serviceSource);
|
||||
const submissionFile = parse(submissionPath, submissionSource);
|
||||
const serviceClass = serviceFile.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === 'SendChainService',
|
||||
);
|
||||
const submissionClass = submissionFile.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === 'SendSubmissionService',
|
||||
);
|
||||
if (!serviceClass || !submissionClass) throw new Error('R9 send-chain facade or submission domain is missing');
|
||||
|
||||
const methodsByName = (classNode) => new Map(
|
||||
classNode.members
|
||||
.filter((member) => ts.isMethodDeclaration(member) && member.name && ts.isIdentifier(member.name))
|
||||
.map((member) => [member.name.text, member]),
|
||||
);
|
||||
const facadeMethods = methodsByName(serviceClass);
|
||||
const submissionMethods = methodsByName(submissionClass);
|
||||
const domainSources = new Map();
|
||||
const domainMethods = new Map();
|
||||
for (const domain of contract.domains) {
|
||||
const filePath = path.join(root, 'api/src/send-chain', domain.file);
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const file = parse(filePath, source);
|
||||
const classNode = file.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === domain.className,
|
||||
);
|
||||
if (!classNode) throw new Error(`R9 domain class missing: ${domain.className}`);
|
||||
const methods = methodsByName(classNode);
|
||||
const migratedCount = contract.methods.filter((method) => method.file === domain.file).length;
|
||||
if (migratedCount !== domain.methodCount) {
|
||||
throw new Error(`R9 domain contract count changed: ${domain.file}`);
|
||||
}
|
||||
domainSources.set(domain.file, { source, file });
|
||||
domainMethods.set(domain.file, methods);
|
||||
}
|
||||
const combinedDomainSource = [...domainSources.values()].map((item) => item.source).join('\n');
|
||||
const digest = (value) =>
|
||||
crypto.createHash('sha256').update(value.replace(/\r\n/g, '\n')).digest('hex');
|
||||
const normalizeFacadeSeams = (value) => value.replaceAll('this.facade.', 'this.');
|
||||
|
||||
for (const expected of contract.methods) {
|
||||
const domain = domainSources.get(expected.file);
|
||||
const migrated = domainMethods.get(expected.file)?.get(expected.name);
|
||||
if (!migrated?.body) throw new Error(`R9 migrated method missing: ${expected.name}`);
|
||||
if (digest(normalizeFacadeSeams(migrated.body.getText(domain.file))) !== expected.bodySha256) {
|
||||
throw new Error(`R9 migrated method body changed: ${expected.name}`);
|
||||
}
|
||||
const internalFacade = submissionMethods.get(expected.name);
|
||||
if (!internalFacade?.body || !internalFacade.body.getText(submissionFile).includes(`.${expected.name}(`)) {
|
||||
throw new Error(`R9 internal submission facade does not delegate ${expected.name}`);
|
||||
}
|
||||
const facade = facadeMethods.get(expected.name);
|
||||
if (!facade?.body) throw new Error(`R9 stable facade method missing: ${expected.name}`);
|
||||
const body = facade.body.getText(serviceFile);
|
||||
if (!body.includes(`this.submission.${expected.name}(`)) {
|
||||
throw new Error(`R9 facade does not delegate ${expected.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const retained of [
|
||||
'handleSubmitSegmentResult',
|
||||
'handleSubmitResult',
|
||||
'intakeReceipt',
|
||||
'handleReceipt',
|
||||
'retryMessageIfAllowed',
|
||||
'recordCmppFailureReceipt',
|
||||
'refundMessage',
|
||||
'queueAndTryDownstreamDelivery',
|
||||
]) {
|
||||
const method = facadeMethods.get(retained);
|
||||
if (!method?.body || method.body.getText(serviceFile).includes('this.submission.')) {
|
||||
throw new Error(`R9 moved R10 behavior out of the facade: ${retained}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const invariant of [
|
||||
"new Logger('SendChainService')",
|
||||
'this.prisma.$transaction',
|
||||
'this.callbacks.releaseMessageReservation',
|
||||
'this.callbacks.recordCmppFailureReceipt',
|
||||
'GATEWAY_SUBMIT_STREAM',
|
||||
'await this.facade.publishGatewaySubmitCommand(command);',
|
||||
'new Worker<SendJob>(',
|
||||
]) {
|
||||
if (!combinedDomainSource.includes(invariant)) {
|
||||
throw new Error(`R9 submission invariant missing: ${invariant}`);
|
||||
}
|
||||
}
|
||||
|
||||
const moduleSource = fs.readFileSync(path.join(root, 'api/src/send-chain/send-chain.module.ts'), 'utf8');
|
||||
if (!moduleSource.includes('providers: [SendChainService]') || moduleSource.includes('SendSubmissionService,')) {
|
||||
throw new Error('R9 changed the public NestJS provider boundary');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R9 send submission verified: ${contract.methods.length} methods across ${contract.domains.length} domains; `
|
||||
+ `${facadeMethods.size} stable facade methods, unchanged bodies and R10 boundaries preserved.`,
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const root = process.cwd();
|
||||
const contract = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'docs/contracts/shared-components-r11.json'), 'utf8'),
|
||||
);
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const failures = [];
|
||||
|
||||
function mediaOf(rule) {
|
||||
let parent = rule.parent;
|
||||
while (parent) {
|
||||
if (parent.type === 'atrule' && parent.name === 'media') return parent.params;
|
||||
parent = parent.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizedDeclarations(rule) {
|
||||
return rule.nodes
|
||||
.filter((node) => node.type === 'decl')
|
||||
.map((node) => `${node.prop}:${node.value}${node.important ? ' !important' : ''}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function selectorClasses(selector) {
|
||||
return (selector.match(/\.([a-zA-Z0-9_-]+)/g) ?? []).map((value) => value.slice(1));
|
||||
}
|
||||
|
||||
function isPureSharedSelector(selector) {
|
||||
const classes = selectorClasses(selector);
|
||||
const generic = new Set(contract.genericClasses);
|
||||
const states = new Set(contract.allowedStateClasses);
|
||||
const ownsSharedClass = classes.some((className) => className.startsWith('ui-') || generic.has(className));
|
||||
return classes.length > 0
|
||||
&& ownsSharedClass
|
||||
&& classes.every((className) => className.startsWith('ui-') || generic.has(className) || states.has(className));
|
||||
}
|
||||
|
||||
const entry = read(contract.entry);
|
||||
const globalImport = entry.indexOf(`import '@/styles/global.css';`);
|
||||
const componentImport = entry.indexOf(`import '@/styles/components.css';`);
|
||||
if (globalImport < 0 || componentImport < 0 || globalImport >= componentImport) {
|
||||
failures.push(`${contract.componentsFile} must load after ${contract.legacyFile}`);
|
||||
}
|
||||
|
||||
const componentsSource = read(contract.componentsFile);
|
||||
const markerIndexes = [
|
||||
componentsSource.indexOf(contract.compatibilityMarker),
|
||||
componentsSource.indexOf(contract.canonicalMarker),
|
||||
componentsSource.indexOf(contract.responsiveMarker),
|
||||
];
|
||||
if (markerIndexes.some((index) => index < 0) || !(markerIndexes[0] < markerIndexes[1] && markerIndexes[1] < markerIndexes[2])) {
|
||||
failures.push('compatibility, canonical and responsive component sections are missing or out of order');
|
||||
}
|
||||
|
||||
const componentsRoot = postcss.parse(componentsSource, { from: contract.componentsFile });
|
||||
const componentRules = [];
|
||||
componentsRoot.walkRules((rule) => componentRules.push(rule));
|
||||
const selectorCount = componentRules.reduce((total, rule) => total + rule.selectors.length, 0);
|
||||
if (componentRules.length < contract.minimumRuleCount) {
|
||||
failures.push(`${contract.componentsFile} has ${componentRules.length} rules; expected at least ${contract.minimumRuleCount}`);
|
||||
}
|
||||
if (selectorCount < contract.minimumSelectorCount) {
|
||||
failures.push(`${contract.componentsFile} has ${selectorCount} selectors; expected at least ${contract.minimumSelectorCount}`);
|
||||
}
|
||||
|
||||
for (const requirement of contract.requiredRuleFragments) {
|
||||
const matches = componentRules.filter(
|
||||
(rule) => rule.selectors.includes(requirement.selector) && mediaOf(rule) === requirement.media,
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
failures.push(
|
||||
`${contract.componentsFile} is missing ${requirement.selector}`
|
||||
+ `${requirement.media ? ` in @media ${requirement.media}` : ' outside media queries'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const declarations = matches.map(normalizedDeclarations).join(';');
|
||||
for (const fragment of requirement.includes) {
|
||||
if (!declarations.includes(fragment)) {
|
||||
failures.push(`${requirement.selector} is missing declaration ${fragment}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyRoot = postcss.parse(read(contract.legacyFile), { from: contract.legacyFile });
|
||||
legacyRoot.walkRules((rule) => {
|
||||
for (const selector of rule.selectors) {
|
||||
if (isPureSharedSelector(selector)) {
|
||||
failures.push(`${contract.legacyFile} still owns shared component selector ${selector}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [file, classNames] of Object.entries(contract.requiredComponentBindings)) {
|
||||
const source = read(file);
|
||||
for (const className of classNames) {
|
||||
if (!source.includes(className)) failures.push(`${file} is missing component class ${className}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`R11 shared components verification failed:\n- ${[...new Set(failures)].join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R11 shared components verified: ${componentRules.length} rules, ${selectorCount} selectors, `
|
||||
+ `${contract.genericClasses.length} generic class families and desktop/mobile ownership are isolated.`,
|
||||
);
|
||||
@@ -0,0 +1,191 @@
|
||||
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.`,
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type declaration struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Receiver string `json:"receiver,omitempty"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Version string `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Declarations []declaration `json:"declarations"`
|
||||
}
|
||||
|
||||
var requiredFiles = []string{
|
||||
"manager.go",
|
||||
"pool.go",
|
||||
"connection.go",
|
||||
"reconnect.go",
|
||||
"flow_control.go",
|
||||
"submit.go",
|
||||
"deliver.go",
|
||||
"protocol_log.go",
|
||||
"transport.go",
|
||||
}
|
||||
|
||||
var requiredTests = []string{
|
||||
"TestConnectionPoolAcquiresAcrossConnections",
|
||||
"TestManagerDisconnectChannelRemovesPoolAndStopsReconnects",
|
||||
"TestNormalizeUpstreamConfigDefaults",
|
||||
"TestFailedSupplierConnectionReconnectsWhenEndpointRecovers",
|
||||
"TestHandleConnectionLossNotifiesPendingSubmitters",
|
||||
"TestHeartbeatTimeoutClosesConnectionAndSchedulesReconnect",
|
||||
"TestReconnectDelayUsesCappedBackoffAndSlowAuthenticationRetry",
|
||||
"TestHeartbeatResponseClearsOnlyMatchingRequest",
|
||||
"TestSplitSubmitContentUCS2LongMessage",
|
||||
"TestAssembleLongUplinkOutOfOrder",
|
||||
"TestSubmitRequestPacketUsesCMPP2PacketForCMPP20Channel",
|
||||
"TestSubmitRequestPacketUsesCMPP3PacketForCMPP30Channel",
|
||||
"TestHandleCMPP2DeliverReceiptPostsReceiptEvent",
|
||||
"TestReceiptStatusTreatsNonDeliveredFinalStatesAsUndelivered",
|
||||
"TestEmitProtocolLogPostsSafeOutboundPacketEvent",
|
||||
}
|
||||
|
||||
func main() {
|
||||
root, err := os.Getwd()
|
||||
must(err)
|
||||
payload, err := os.ReadFile(filepath.Join(root, "docs", "contracts", "upstream-r7-declarations.json"))
|
||||
must(err)
|
||||
var contract manifest
|
||||
must(json.Unmarshal(payload, &contract))
|
||||
if contract.Version != "R7" {
|
||||
fail("unexpected contract version %q", contract.Version)
|
||||
}
|
||||
|
||||
upstreamDir := filepath.Join(root, "gateway", "internal", "upstream")
|
||||
actual := map[string]declaration{}
|
||||
for _, name := range requiredFiles {
|
||||
path := filepath.Join(upstreamDir, name)
|
||||
source, err := os.ReadFile(path)
|
||||
must(err)
|
||||
if !bytes.HasPrefix(source, []byte("package upstream")) {
|
||||
fail("%s must remain in package upstream", name)
|
||||
}
|
||||
for _, item := range declarationsInFile(path, source) {
|
||||
key := declarationKey(item)
|
||||
if previous, exists := actual[key]; exists {
|
||||
fail("duplicate declaration %s in %s and %s", key, previous.File, item.File)
|
||||
}
|
||||
actual[key] = item
|
||||
}
|
||||
}
|
||||
for _, expected := range contract.Declarations {
|
||||
key := declarationKey(expected)
|
||||
item, ok := actual[key]
|
||||
if !ok {
|
||||
fail("missing declaration %s expected in %s", key, expected.File)
|
||||
}
|
||||
if item.File != expected.File {
|
||||
fail("%s moved to %s, expected %s", key, item.File, expected.File)
|
||||
}
|
||||
if item.SHA256 != expected.SHA256 {
|
||||
fail("%s implementation changed: %s != %s", key, item.SHA256, expected.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
managerSource, err := os.ReadFile(filepath.Join(upstreamDir, "manager.go"))
|
||||
must(err)
|
||||
if lines := bytes.Count(managerSource, []byte{'\n'}); lines > 250 {
|
||||
fail("stable manager.go grew to %d lines", lines)
|
||||
}
|
||||
for _, entry := range []string{
|
||||
"type Manager struct",
|
||||
"type ConnectionState struct",
|
||||
"func (m *Manager) ConnectChannel(",
|
||||
"func (m *Manager) DisconnectChannel(",
|
||||
} {
|
||||
assertContains(managerSource, entry)
|
||||
}
|
||||
submitSource, err := os.ReadFile(filepath.Join(upstreamDir, "submit.go"))
|
||||
must(err)
|
||||
assertContains(submitSource, "func (m *Manager) Submit(")
|
||||
|
||||
controlSource, err := os.ReadFile(filepath.Join(root, "gateway", "internal", "control", "server.go"))
|
||||
must(err)
|
||||
for _, entry := range []string{
|
||||
"*upstream.Manager",
|
||||
"s.Upstream.ConnectChannel",
|
||||
"s.Upstream.DisconnectChannel",
|
||||
"server.Upstream.Submit",
|
||||
} {
|
||||
assertContains(controlSource, entry)
|
||||
}
|
||||
mainSource, err := os.ReadFile(filepath.Join(root, "gateway", "cmd", "gateway", "main.go"))
|
||||
must(err)
|
||||
assertContains(mainSource, "&upstream.Manager{APIBaseURL: apiBaseURL}")
|
||||
|
||||
longMessageSource, err := os.ReadFile(filepath.Join(upstreamDir, "long_message.go"))
|
||||
must(err)
|
||||
for _, entry := range []string{
|
||||
"func splitSubmitContent(",
|
||||
"func assembleLongUplink(",
|
||||
"func pruneLongUplinkAssemblies(",
|
||||
} {
|
||||
assertContains(longMessageSource, entry)
|
||||
}
|
||||
|
||||
var tests bytes.Buffer
|
||||
testFiles, err := filepath.Glob(filepath.Join(upstreamDir, "*_test.go"))
|
||||
must(err)
|
||||
for _, path := range testFiles {
|
||||
source, err := os.ReadFile(path)
|
||||
must(err)
|
||||
tests.Write(source)
|
||||
}
|
||||
for _, test := range requiredTests {
|
||||
assertContains(tests.Bytes(), "func "+test+"(")
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"R7 upstream facade verified: %d declarations across %d focused files; stable entries and %d critical tests preserved.\n",
|
||||
len(contract.Declarations), len(requiredFiles), len(requiredTests),
|
||||
)
|
||||
}
|
||||
|
||||
func declarationsInFile(path string, source []byte) []declaration {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, source, parser.ParseComments)
|
||||
must(err)
|
||||
var result []declaration
|
||||
for _, decl := range file.Decls {
|
||||
if gen, ok := decl.(*ast.GenDecl); ok && gen.Tok == token.IMPORT {
|
||||
continue
|
||||
}
|
||||
name, kind, receiver := declarationIdentity(decl)
|
||||
start := declarationStart(decl)
|
||||
code := strings.TrimSpace(string(source[fset.Position(start).Offset:fset.Position(decl.End()).Offset]))
|
||||
hash := sha256.Sum256([]byte(strings.ReplaceAll(code, "\r\n", "\n")))
|
||||
result = append(result, declaration{
|
||||
Name: name, Kind: kind, Receiver: receiver, File: filepath.Base(path),
|
||||
SHA256: hex.EncodeToString(hash[:]),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func declarationIdentity(decl ast.Decl) (name string, kind string, receiver string) {
|
||||
switch typed := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
return typed.Name.Name, "func", receiverName(typed)
|
||||
case *ast.GenDecl:
|
||||
if len(typed.Specs) == 0 {
|
||||
fail("empty declaration")
|
||||
}
|
||||
switch spec := typed.Specs[0].(type) {
|
||||
case *ast.TypeSpec:
|
||||
return spec.Name.Name, "type", ""
|
||||
case *ast.ValueSpec:
|
||||
return spec.Names[0].Name, strings.ToLower(typed.Tok.String()), ""
|
||||
}
|
||||
}
|
||||
fail("unsupported declaration %T", decl)
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
func receiverName(decl *ast.FuncDecl) string {
|
||||
if decl.Recv == nil || len(decl.Recv.List) == 0 {
|
||||
return ""
|
||||
}
|
||||
switch typed := decl.Recv.List[0].Type.(type) {
|
||||
case *ast.Ident:
|
||||
return typed.Name
|
||||
case *ast.StarExpr:
|
||||
if ident, ok := typed.X.(*ast.Ident); ok {
|
||||
return ident.Name
|
||||
}
|
||||
}
|
||||
fail("unsupported receiver for %s", decl.Name.Name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func declarationStart(decl ast.Decl) token.Pos {
|
||||
start := decl.Pos()
|
||||
switch typed := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if typed.Doc != nil {
|
||||
start = typed.Doc.Pos()
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
if typed.Doc != nil {
|
||||
start = typed.Doc.Pos()
|
||||
}
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func declarationKey(item declaration) string {
|
||||
key := item.Kind + ":"
|
||||
if item.Receiver != "" {
|
||||
key += item.Receiver + "."
|
||||
}
|
||||
return key + item.Name
|
||||
}
|
||||
|
||||
func assertContains(source []byte, fragment string) {
|
||||
if !bytes.Contains(source, []byte(fragment)) {
|
||||
fail("required invariant not found: %s", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "R7 verification failed: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user