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