274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const REQUIRED_PACKAGE_MANAGER = "pnpm@10.33.0";
|
|
const REQUIRED_SCRIPTS = ["typecheck", "lint", "test", "test:packages", "test:mobile", "build", "build:ci"];
|
|
const REQUIRED_FILES = ["package.json", "pnpm-lock.yaml", ".eslintrc"];
|
|
const REQUIRED_SCRIPT_BODIES = {
|
|
build: "pnpm --filter @toolctrl/db prisma:generate && pnpm --filter @toolctrl/db build && pnpm --filter @toolctrl/web build",
|
|
"build:ci": "pnpm run build",
|
|
"verify:i18n": "tsx scripts/i18n-check.ts && node scripts/verify-hardcoded-strings.mjs && eslint --max-warnings=0 --ext .tsx,.ts packages/ui-app/src packages/ui-subframe/src packages/ui-mock/src",
|
|
"verify:ui-hot-swap": "tsx scripts/verify-ui-hot-swap.ts",
|
|
"test:hot-swap": "pnpm exec vitest run scripts/verify-ui-hot-swap.test.ts && pnpm run verify:ui-hot-swap",
|
|
"verify:test-coverage": "node scripts/verify-test-coverage.mjs",
|
|
"verify:sidebar-routes": "pnpm exec vitest run apps/web/app/application/sidebar/AdminSidebar.test.tsx apps/web/app/application/sidebar/AppSidebar.test.tsx apps/web/app/components/layouts/sidebars/shadcn/app-sidebar.test.tsx apps/web/app/routes/admin/admin-route-targets.test.ts apps/web/app/routes.test.ts",
|
|
"verify:a11y-coverage": "node scripts/verify-a11y-coverage.mjs",
|
|
"test:a11y": "pnpm exec vitest run apps/web/app/components/ui/button.a11y.test.tsx apps/web/app/components/ui/errors/ErrorPage.a11y.test.tsx apps/web/app/components/ui/*.axe.test.tsx apps/web/app/components/core/SkipToContent.test.tsx apps/web/app/components/aceternity/HoverEffect.a11y.test.tsx apps/web/app/components/ui/lists/ViewToggle.a11y.test.tsx apps/web/app/components/ui/lists/ViewToggleWithUrl.a11y.test.tsx apps/web/app/components/ui/tabs/TabsWithIcons.a11y.test.tsx scripts/verify-color-contrast.test.mjs",
|
|
"test:mobile": "pnpm --filter @toolctrl/mobile test",
|
|
};
|
|
const PHASE_C_UI_GLOBS = ["packages/ui-app/src", "packages/ui-subframe/src", "packages/ui-mock/src"];
|
|
const PHASE_C_I18N_SELECTOR_SNIPPETS = [
|
|
"Property[key.name=/^(title|label|placeholder|ariaLabel|alt|description|message|heading|subject)$/] > Literal",
|
|
"Property[key.value=/^(title|label|placeholder|aria-label|ariaLabel|alt|description|message|heading|subject)$/] > Literal",
|
|
"Property[key.name=/^(title|label|placeholder|ariaLabel|alt|description|message|heading|subject)$/] > TemplateLiteral",
|
|
"Property[key.value=/^(title|label|placeholder|aria-label|ariaLabel|alt|description|message|heading|subject)$/] > TemplateLiteral",
|
|
"JSXText",
|
|
"JSXAttribute[name.name=/^(aria-label|title|placeholder)$/] > Literal",
|
|
];
|
|
|
|
function readJsonFile(rootDir, relativePath) {
|
|
return JSON.parse(fs.readFileSync(path.join(rootDir, relativePath), "utf8"));
|
|
}
|
|
|
|
function checkFileExists(rootDir, relativePath) {
|
|
return fs.existsSync(path.join(rootDir, relativePath));
|
|
}
|
|
|
|
function hasScript(manifest, scriptName) {
|
|
return typeof manifest.scripts?.[scriptName] === "string" && manifest.scripts[scriptName].trim().length > 0;
|
|
}
|
|
|
|
function isWarnRule(ruleValue) {
|
|
if (ruleValue === "warn" || ruleValue === 1) {
|
|
return true;
|
|
}
|
|
|
|
return Array.isArray(ruleValue) && (ruleValue[0] === "warn" || ruleValue[0] === 1);
|
|
}
|
|
|
|
export function checkRequiredFiles(rootDir) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
for (const relativePath of REQUIRED_FILES) {
|
|
if (checkFileExists(rootDir, relativePath)) {
|
|
passes.push(`${relativePath}: exists`);
|
|
} else {
|
|
failures.push(`${relativePath}: missing required CI baseline file`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkPackageManagerAndScripts(rootDir) {
|
|
const relativePath = "package.json";
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
return { passes, failures: [`${relativePath}: missing required CI baseline file`], observations: [] };
|
|
}
|
|
|
|
let manifest;
|
|
try {
|
|
manifest = readJsonFile(rootDir, relativePath);
|
|
} catch (error) {
|
|
return { passes, failures: [`${relativePath}: failed to read manifest JSON: ${error.message}`], observations: [] };
|
|
}
|
|
|
|
if (manifest.packageManager === REQUIRED_PACKAGE_MANAGER) {
|
|
passes.push(`${relativePath}: packageManager is ${REQUIRED_PACKAGE_MANAGER}`);
|
|
} else {
|
|
failures.push(`${relativePath}: packageManager must be exactly ${REQUIRED_PACKAGE_MANAGER}`);
|
|
}
|
|
|
|
for (const scriptName of REQUIRED_SCRIPTS) {
|
|
if (hasScript(manifest, scriptName)) {
|
|
passes.push(`${relativePath}: script "${scriptName}" is defined`);
|
|
} else {
|
|
failures.push(`${relativePath}: missing required script "${scriptName}"`);
|
|
}
|
|
}
|
|
|
|
for (const [scriptName, expectedCommand] of Object.entries(REQUIRED_SCRIPT_BODIES)) {
|
|
const actualCommand = manifest.scripts?.[scriptName];
|
|
if (actualCommand === expectedCommand) {
|
|
passes.push(`${relativePath}: script "${scriptName}" matches the required CI command`);
|
|
} else if (typeof actualCommand === "string" && actualCommand.trim().length > 0) {
|
|
failures.push(`${relativePath}: script "${scriptName}" must be exactly "${expectedCommand}"`);
|
|
} else {
|
|
failures.push(`${relativePath}: missing required script "${scriptName}"`);
|
|
}
|
|
}
|
|
|
|
if (hasScript(manifest, "lint")) {
|
|
const lintCommand = manifest.scripts.lint;
|
|
if (/\beslint\b/.test(lintCommand)) {
|
|
passes.push(`${relativePath}: script "lint" invokes ESLint`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "lint" must invoke ESLint`);
|
|
}
|
|
if (/\s--max-warnings=0(?:\s|$)/.test(` ${lintCommand} `)) {
|
|
passes.push(`${relativePath}: script "lint" fails on ESLint warnings`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "lint" must include --max-warnings=0`);
|
|
}
|
|
}
|
|
|
|
if (hasScript(manifest, "verify:i18n")) {
|
|
const i18nCommand = manifest.scripts["verify:i18n"];
|
|
if (/\beslint\b/.test(i18nCommand) && PHASE_C_UI_GLOBS.every((uiGlob) => i18nCommand.includes(uiGlob))) {
|
|
passes.push(`${relativePath}: script "verify:i18n" runs ESLint over Phase C UI packages`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "verify:i18n" must run ESLint over Phase C UI packages`);
|
|
}
|
|
if (i18nCommand.includes("--no-error-on-unmatched-pattern")) {
|
|
failures.push(`${relativePath}: script "verify:i18n" must not include --no-error-on-unmatched-pattern`);
|
|
} else {
|
|
passes.push(`${relativePath}: script "verify:i18n" fails when configured UI lint roots disappear`);
|
|
}
|
|
if (/\s--max-warnings=0(?:\s|$)/.test(` ${i18nCommand} `)) {
|
|
passes.push(`${relativePath}: script "verify:i18n" fails on ESLint warnings`);
|
|
} else {
|
|
failures.push(`${relativePath}: script "verify:i18n" must include --max-warnings=0`);
|
|
}
|
|
} else {
|
|
failures.push(`${relativePath}: missing required script "verify:i18n"`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkPhaseCUiLintRoots(rootDir) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
for (const uiRoot of PHASE_C_UI_GLOBS) {
|
|
const absoluteRoot = path.join(rootDir, uiRoot);
|
|
if (!fs.existsSync(absoluteRoot) || !fs.statSync(absoluteRoot).isDirectory()) {
|
|
failures.push(`${uiRoot}: missing Phase C UI lint root`);
|
|
continue;
|
|
}
|
|
const lintableFiles = listLintableFiles(absoluteRoot);
|
|
if (lintableFiles.length === 0) {
|
|
failures.push(`${uiRoot}: Phase C UI lint root contains no TypeScript files`);
|
|
} else {
|
|
passes.push(`${uiRoot}: Phase C UI lint root contains ${lintableFiles.length} TypeScript file(s)`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
function listLintableFiles(absoluteRoot) {
|
|
const files = [];
|
|
for (const entry of fs.readdirSync(absoluteRoot, { withFileTypes: true })) {
|
|
const absolutePath = path.join(absoluteRoot, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...listLintableFiles(absolutePath));
|
|
} else if (/\.(?:ts|tsx)$/.test(entry.name)) {
|
|
files.push(absolutePath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
export function checkEslintBaseline(rootDir) {
|
|
const relativePath = ".eslintrc";
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (!checkFileExists(rootDir, relativePath)) {
|
|
return { passes, failures: [`${relativePath}: missing required CI baseline file`], observations: [] };
|
|
}
|
|
|
|
let eslintConfig;
|
|
try {
|
|
eslintConfig = readJsonFile(rootDir, relativePath);
|
|
} catch (error) {
|
|
return { passes, failures: [`${relativePath}: failed to read ESLint config JSON: ${error.message}`], observations: [] };
|
|
}
|
|
|
|
const rules = eslintConfig.rules ?? {};
|
|
const requiredWarnRules = ["no-console", "@typescript-eslint/no-unused-vars"];
|
|
|
|
for (const ruleName of requiredWarnRules) {
|
|
if (isWarnRule(rules[ruleName])) {
|
|
passes.push(`${relativePath}: rule "${ruleName}" is warn`);
|
|
} else {
|
|
failures.push(`${relativePath}: rule "${ruleName}" must be warn for the current CI baseline`);
|
|
}
|
|
}
|
|
|
|
const restrictedSyntaxSelectors = getPhaseCRestrictedSyntaxSelectors(eslintConfig);
|
|
for (const selectorSnippet of PHASE_C_I18N_SELECTOR_SNIPPETS) {
|
|
if (restrictedSyntaxSelectors.some((selector) => selector.includes(selectorSnippet))) {
|
|
passes.push(`${relativePath}: Phase C i18n ESLint selector "${selectorSnippet}" is enforced`);
|
|
} else {
|
|
failures.push(`${relativePath}: missing Phase C i18n ESLint selector "${selectorSnippet}"`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
function getPhaseCRestrictedSyntaxSelectors(eslintConfig) {
|
|
const selectors = [];
|
|
for (const override of eslintConfig.overrides ?? []) {
|
|
const files = Array.isArray(override.files) ? override.files : [override.files].filter(Boolean);
|
|
const targetsPhaseCUi = PHASE_C_UI_GLOBS.every((uiGlob) => files.some((filePattern) => String(filePattern).includes(uiGlob)));
|
|
const rule = override.rules?.["no-restricted-syntax"];
|
|
if (!targetsPhaseCUi || !Array.isArray(rule) || rule[0] !== "error") {
|
|
continue;
|
|
}
|
|
for (const entry of rule.slice(1)) {
|
|
if (entry && typeof entry.selector === "string") {
|
|
selectors.push(entry.selector);
|
|
}
|
|
}
|
|
}
|
|
return selectors;
|
|
}
|
|
|
|
export function verifyCiBaseline(rootDir = process.cwd()) {
|
|
const checks = [checkRequiredFiles(rootDir), checkPackageManagerAndScripts(rootDir), checkEslintBaseline(rootDir), checkPhaseCUiLintRoots(rootDir)];
|
|
|
|
return checks.reduce(
|
|
(result, check) => ({
|
|
passes: [...result.passes, ...check.passes],
|
|
failures: [...result.failures, ...check.failures],
|
|
observations: [...result.observations, ...check.observations],
|
|
}),
|
|
{ passes: [], failures: [], observations: [] },
|
|
);
|
|
}
|
|
|
|
export function formatCiBaselineResult(result) {
|
|
const status = result.failures.length > 0 ? "FAIL" : "PASS";
|
|
return [
|
|
`${status} CI baseline verification`,
|
|
...result.passes.map((pass) => `PASS ${pass}`),
|
|
...result.observations.map((observation) => `OBSERVE ${observation}`),
|
|
...result.failures.map((failure) => `FAIL ${failure}`),
|
|
`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const result = verifyCiBaseline(process.cwd());
|
|
const output = formatCiBaselineResult(result);
|
|
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|