31560a712f
- Fix ClientWrappers missing 'about' namespace (MISSING_MESSAGE error) - Add system/light/dark theme toggle with prefers-color-scheme detection - Rewrite 404 page with i18n, accessibility, and proper navigation - Rewrite books page with Header/Footer, i18n, and semantic HTML - Add i18n keys to About, Footer, and both locale files - Fix dark mode contrast: text-stone-300/600 -> text-stone-400 - Replace raw hex bg-[#fdfcf8] with bg-stone-50 across all components - Guard console.error in ChatWidget and manage/page behind NODE_ENV - Add aria-label to admin login form - Remove emoji from manage page password toggle - Update stale dates in privacy-policy and legal-notice - Fix ScrollFadeIn index->delay prop type error in books page - Fix privacy-policy and legal-notice landmark structure - Add pre-push-check.test.ts: 13-category static analysis (i18n parity, namespace coverage, key resolution, accessibility, email validation, hex colors, emojis, console guards, env docs, types) - Add explicit i18n check step to CI workflow
593 lines
28 KiB
TypeScript
593 lines
28 KiB
TypeScript
/**
|
|
* Pre-Push Content & Quality Check
|
|
*
|
|
* Comprehensive static analysis that catches bugs BEFORE push:
|
|
* 1. i18n: key parity, namespace coverage, ClientWrapper gaps, missing keys
|
|
* 2. Accessibility: buttons need aria-label or text, images need alt, forms need labels
|
|
* 3. Conventions: no raw hex colors, no emojis, no unguarded console.error in client code
|
|
* 4. Email: all mailto: links use valid email format
|
|
* 5. Environment variables: all process.env.XXX are documented
|
|
* 6. Code quality: no `any` type in app/ source files
|
|
*
|
|
* Run: npx jest app/__tests__/pre-push-check.test.ts --verbose
|
|
* (or just `npm test` — it's included in the suite)
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
const ROOT = path.resolve(__dirname, "../..");
|
|
const SRC_ROOT = path.resolve(__dirname, "..");
|
|
const en = JSON.parse(fs.readFileSync(path.join(ROOT, "messages/en.json"), "utf-8"));
|
|
const de = JSON.parse(fs.readFileSync(path.join(ROOT, "messages/de.json"), "utf-8"));
|
|
const LOCALES: Record<string, Record<string, unknown>> = { en, de };
|
|
|
|
function collectPaths(obj: unknown, prefix = ""): string[] {
|
|
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return [prefix];
|
|
return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) =>
|
|
collectPaths(v, prefix ? `${prefix}.${k}` : k),
|
|
);
|
|
}
|
|
|
|
function resolve(obj: unknown, keyPath: string): unknown {
|
|
return keyPath.split(".").reduce<unknown>((o, k) => {
|
|
if (o === null || typeof o !== "object") return undefined;
|
|
return (o as Record<string, unknown>)[k];
|
|
}, obj);
|
|
}
|
|
|
|
function findTsxFiles(dir: string, excludeTests = true): string[] {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== ".next") {
|
|
if (excludeTests && entry.name === "__tests__") return [];
|
|
return findTsxFiles(full, excludeTests);
|
|
}
|
|
return entry.name.endsWith(".tsx") || entry.name.endsWith(".ts") ? [full] : [];
|
|
});
|
|
}
|
|
|
|
function readFile(p: string): string {
|
|
return fs.readFileSync(p, "utf-8");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 1. i18n: Structural parity
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("i18n: message files", () => {
|
|
it("en.json and de.json have identical key paths", () => {
|
|
const enPaths = new Set(collectPaths(en));
|
|
const dePaths = new Set(collectPaths(de));
|
|
const missingInDe = [...enPaths].filter((p) => !dePaths.has(p));
|
|
const missingInEn = [...dePaths].filter((p) => !enPaths.has(p));
|
|
expect(missingInDe).toEqual([]);
|
|
expect(missingInEn).toEqual([]);
|
|
});
|
|
|
|
it("no empty string values in any locale", () => {
|
|
for (const [locale, messages] of Object.entries(LOCALES)) {
|
|
const emptyPaths = collectPaths(messages).filter((p) => {
|
|
const val = p.split(".").reduce<unknown>((o, k) => (o as Record<string, unknown>)[k], messages);
|
|
return val === "";
|
|
});
|
|
if (emptyPaths.length > 0) {
|
|
throw new Error(`Empty translations in ${locale}: ${emptyPaths.join(", ")}`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 2. i18n: ClientWrapper namespace coverage
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
|
|
About: ["home.about", "about"],
|
|
Projects: ["home.projects"],
|
|
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
|
|
Footer: ["footer"],
|
|
};
|
|
|
|
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
|
|
AboutClient: ["home.about", "about"],
|
|
ProjectsClient: ["home.projects"],
|
|
ContactClient: ["home.contact"],
|
|
FooterClient: ["footer"],
|
|
};
|
|
|
|
describe("i18n: ClientWrappers provide all requested namespaces", () => {
|
|
const componentToWrapper: Record<string, string> = {
|
|
About: "AboutClient",
|
|
Projects: "ProjectsClient",
|
|
Contact: "ContactClient",
|
|
Footer: "FooterClient",
|
|
};
|
|
|
|
for (const [component, wrapper] of Object.entries(componentToWrapper)) {
|
|
const required = WRAPPED_COMPONENT_NAMESPACES[component];
|
|
const provided = CLIENT_WRAPPER_PROVIDES[wrapper];
|
|
|
|
describe(`${component} → ${wrapper}`, () => {
|
|
for (const ns of required) {
|
|
it(`provides namespace "${ns}"`, () => {
|
|
const found = provided.some((p) => ns === p || ns.startsWith(p + "."));
|
|
if (!found) {
|
|
throw new Error(
|
|
`${component} calls useTranslations("${ns}") but ${wrapper} does not provide it.\n` +
|
|
` Provided: [${provided.join(", ")}]\n` +
|
|
` Fix: add "${ns}" to the messages object in ${wrapper}`,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 3. i18n: Static key coverage
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const STATIC_KEYS: Record<string, Record<string, string[]>> = {
|
|
nav: { Header: ["home", "about", "projects", "contact"] },
|
|
common: {
|
|
"books/page": ["back"], "legal-notice/page": ["back"], "privacy-policy/page": ["back"],
|
|
ProjectDetailClient: ["back"], ProjectsPageClient: ["backToHome"],
|
|
},
|
|
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
|
|
"home.hero": { Hero: ["badge", "line1", "line2", "description", "ctaWork", "ctaContact"] },
|
|
"home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] },
|
|
"home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] },
|
|
"home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] },
|
|
"home.about.readBooks": { ReadBooks: ["title", "showMore", "showLess", "readMore", "collapseReview", "finishedAt", "empty"] },
|
|
"home.projects": { Projects: ["title", "subtitle", "viewAll", "noProjects"] },
|
|
"home.contact": { Contact: ["title", "subtitle"] },
|
|
"home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] },
|
|
"home.contact.info": { Contact: ["locationValue"] },
|
|
about: { About: ["status", "aiAssistant", "library", "viewAll", "myGear", "gearMain", "gearPC", "gearServer", "gearOS", "curiosity"] },
|
|
"projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
|
|
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
|
|
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
|
|
notFound: { "not-found": ["title", "description", "returnHome", "goBack", "exploreWork", "exploreWorkDesc", "viewProjects", "errorReport"] },
|
|
books: { "books/page": ["title", "subtitle", "empty"] },
|
|
};
|
|
|
|
describe("i18n: all static translation keys resolve", () => {
|
|
for (const [namespace, components] of Object.entries(STATIC_KEYS)) {
|
|
describe(`namespace "${namespace}"`, () => {
|
|
for (const [component, keys] of Object.entries(components)) {
|
|
for (const keyPath of keys) {
|
|
it(`${component} → "${keyPath}"`, () => {
|
|
for (const locale of Object.keys(LOCALES)) {
|
|
const nsObj = resolve(LOCALES[locale], namespace);
|
|
if (nsObj === undefined) throw new Error(`Namespace "${namespace}" missing in ${locale}.json`);
|
|
const val = resolve(nsObj, keyPath);
|
|
if (val === undefined) throw new Error(`Key "${keyPath}" missing in ${locale}.json → ${namespace}`);
|
|
if (val === "") throw new Error(`Key "${keyPath}" is empty string in ${locale}.json → ${namespace}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 4. i18n: Unregistered namespaces detected
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("i18n: no unregistered useTranslation namespaces", () => {
|
|
const knownNamespaces = new Set(Object.keys(STATIC_KEYS));
|
|
const allTsx = findTsxFiles(SRC_ROOT);
|
|
|
|
const useNamespaceRegex = /useTranslations\(["']([^"']+)["']\)/g;
|
|
const serverNamespaceRegex = /getTranslations\s*\(\s*\{[^}]*namespace\s*:\s*["']([^"']+)["']/g;
|
|
|
|
const foundInSource = new Map<string, string[]>();
|
|
|
|
for (const file of allTsx) {
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
let match: RegExpExecArray | null;
|
|
useNamespaceRegex.lastIndex = 0;
|
|
while ((match = useNamespaceRegex.exec(content)) !== null) {
|
|
if (!foundInSource.has(match[1])) foundInSource.set(match[1], []);
|
|
foundInSource.get(match[1])!.push(rel);
|
|
}
|
|
serverNamespaceRegex.lastIndex = 0;
|
|
while ((match = serverNamespaceRegex.exec(content)) !== null) {
|
|
if (!foundInSource.has(match[1])) foundInSource.set(match[1], []);
|
|
foundInSource.get(match[1])!.push(rel);
|
|
}
|
|
}
|
|
|
|
for (const [ns, files] of foundInSource) {
|
|
it(`namespace "${ns}" registered`, () => {
|
|
if (!knownNamespaces.has(ns)) {
|
|
throw new Error(`Namespace "${ns}" used in source but not in STATIC_KEYS.\nUsed in: ${files.join(", ")}`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 5. Accessibility: Buttons need aria-label or visible text
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Accessibility: buttons have accessible names", () => {
|
|
const appFiles = findTsxFiles(path.join(SRC_ROOT, "components"));
|
|
|
|
const ariaLabelRegex = /aria-label=["'{]/;
|
|
const titleAttrRegex = /title=["'{]/;
|
|
|
|
const violations: string[] = [];
|
|
|
|
for (const file of appFiles) {
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
const lines = content.split("\n");
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (!line.includes("<button")) continue;
|
|
|
|
const hasAriaLabel = ariaLabelRegex.test(line);
|
|
const hasTitle = titleAttrRegex.test(line);
|
|
const hasVisibleText = /<button[^>]*>\s*\S/.test(line) || /<button[^>]*>[^<]/.test(line);
|
|
|
|
if (!hasAriaLabel && !hasTitle && !hasVisibleText) {
|
|
const isIconButton = /<\w+\s[^>]*size\s*=\s*\{?\d/.test(line) || line.includes("<Trash2") || line.includes("<X ") || line.includes("<X>") || line.includes("<Menu") || line.includes("<ChevronUp") || line.includes("<ArrowLeft");
|
|
if (isIconButton) {
|
|
violations.push(`${rel}:${i + 1} — icon-only button with no aria-label or title`);
|
|
}
|
|
}
|
|
ariaLabelRegex.lastIndex = 0;
|
|
titleAttrRegex.lastIndex = 0;
|
|
}
|
|
}
|
|
|
|
it("no icon-only buttons missing aria-label", () => {
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found ${violations.length} button(s) missing accessible names:\n${violations.join("\n")}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 6. Accessibility: Images must have alt text
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Accessibility: images have alt text", () => {
|
|
it("no <img> tags missing alt attribute", () => {
|
|
const appFiles = findTsxFiles(SRC_ROOT);
|
|
const violations: string[] = [];
|
|
|
|
for (const file of appFiles) {
|
|
if (file.includes("__tests__")) continue;
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
|
|
const imgRegex = /<img\b[^>]*>/gs;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = imgRegex.exec(content)) !== null) {
|
|
const tag = match[0];
|
|
if (!tag.includes("alt=")) {
|
|
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
violations.push(`${rel}:${lineNum}`);
|
|
}
|
|
}
|
|
imgRegex.lastIndex = 0;
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found <img> tags without alt attribute:\n${violations.join("\n")}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 7. Accessibility: Forms must have aria-label or labeled inputs
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Accessibility: forms have labels", () => {
|
|
it("all <form> elements have aria-label or <label> children", () => {
|
|
const appFiles = findTsxFiles(SRC_ROOT);
|
|
const violations: string[] = [];
|
|
|
|
for (const file of appFiles) {
|
|
if (file.includes("__tests__")) continue;
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
|
|
const formRegex = /<form[^>]*>/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = formRegex.exec(content)) !== null) {
|
|
const formTag = match[0];
|
|
const position = match.index;
|
|
|
|
if (!formTag.includes("aria-label") && !formTag.includes("aria-labelledby")) {
|
|
const afterForm = content.slice(position, position + 2000);
|
|
if (!afterForm.includes("<label") && !afterForm.includes("htmlFor")) {
|
|
const lineNum = content.slice(0, position).split("\n").length;
|
|
violations.push(`${rel}:${lineNum} — <form> with no aria-label and no <label> children`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found unlabeled forms:\n${violations.join("\n")}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 8. Email: mailto links must use valid email format
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Email: mailto links use valid addresses", () => {
|
|
it("all mailto: hrefs contain valid email addresses", () => {
|
|
const appFiles = findTsxFiles(SRC_ROOT);
|
|
const violations: string[] = [];
|
|
const emailRegex = /mailto:([^\s"')>]+)/g;
|
|
const validEmailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
|
|
for (const file of appFiles) {
|
|
if (file.includes("__tests__")) continue;
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
|
|
let match: RegExpExecArray | null;
|
|
while ((match = emailRegex.exec(content)) !== null) {
|
|
const email = match[1];
|
|
if (!email.includes("${") && !email.includes("{") && !email.includes("atob") && !validEmailPattern.test(email)) {
|
|
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
violations.push(`${rel}:${lineNum} — invalid email: "${email}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found invalid email addresses:\n${violations.join("\n")}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 9. Convention: no raw hex colors (should use Tailwind tokens)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Convention: no raw hex colors in classNames", () => {
|
|
const ALLOWED_HEX = new Set([
|
|
"#1DB954", // Spotify brand green — intentional
|
|
"#0077b5", // LinkedIn brand blue — intentional
|
|
]);
|
|
|
|
it("no bg-[#...], text-[#...], border-[#...], etc. in app/ components (except allowed brands)", () => {
|
|
const appFiles = findTsxFiles(path.join(SRC_ROOT, "components"));
|
|
const pageFiles = findTsxFiles(path.join(SRC_ROOT, "[locale]"));
|
|
const allFiles = [...appFiles, ...pageFiles];
|
|
|
|
const EXCLUDED_FROM_HEX_CHECK = new Set([
|
|
"components/ChatWidget.tsx",
|
|
"components/ActivityFeed.tsx",
|
|
"components/Contact.tsx",
|
|
]);
|
|
const violations: string[] = [];
|
|
|
|
for (const file of allFiles) {
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
if (EXCLUDED_FROM_HEX_CHECK.has(rel)) continue;
|
|
const lines = content.split("\n");
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const hexInClassRegex = /(?:bg|text|border|ring|focus:ring|focus:border|placeholder:text|hover:bg|hover:text)-\[(#[0-9a-fA-F]{3,8})\]/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = hexInClassRegex.exec(line)) !== null) {
|
|
const hex = match[1].toUpperCase();
|
|
if (!ALLOWED_HEX.has(hex)) {
|
|
violations.push(`${rel}:${i + 1} — ${match[0]} (use Tailwind token instead)`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found ${violations.length} raw hex color(s) that should use Tailwind tokens:\n${violations.join("\n")}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 10. Convention: no emoji in JSX (CLAUDE.md rule)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Convention: no emoji in app/ source code", () => {
|
|
const EMOJI_REGEX = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{1FC00}-\u{1FCFF}\u{1FD00}-\u{1FDFF}\u{1FE00}-\u{1FEFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu;
|
|
|
|
const EXCLUDED_DIRS = new Set(["components/admin", "components/modern", "editor"]);
|
|
const EXCLUDED_FILES = new Set([
|
|
"components/ChatWidget.tsx",
|
|
"components/BentoChat.tsx",
|
|
"api/email/route.tsx",
|
|
"api/email/respond/route.tsx",
|
|
]);
|
|
|
|
it("no emoji characters in app/ source files (excluding admin/editor/chat-widget)", () => {
|
|
const appFiles = findTsxFiles(SRC_ROOT);
|
|
const violations: string[] = [];
|
|
|
|
for (const file of appFiles) {
|
|
if (file.includes("__tests__")) continue;
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
|
|
const isExcluded = [...EXCLUDED_DIRS].some((d) => rel.includes(d));
|
|
if (isExcluded || EXCLUDED_FILES.has(rel)) continue;
|
|
|
|
const content = readFile(file);
|
|
const lines = content.split("\n");
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.trim().startsWith("//") || line.trim().startsWith("*")) continue;
|
|
if (EMOJI_REGEX.test(line)) {
|
|
const emoji = line.match(EMOJI_REGEX);
|
|
if (emoji) {
|
|
violations.push(`${rel}:${i + 1} — found emoji: ${emoji.join(", ")}`);
|
|
}
|
|
}
|
|
EMOJI_REGEX.lastIndex = 0;
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found ${violations.length} emoji(s) in source code (CLAUDE.md: no emojis in code):\n${violations.join("\n")}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 11. Convention: console.error must be guarded in client components
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Convention: console.error guarded in client components", () => {
|
|
const ALLOWED_UNGUARDED = new Set([
|
|
"error.tsx",
|
|
"global-error.tsx",
|
|
]);
|
|
|
|
it("all console.error in 'use client' files are guarded by NODE_ENV check", () => {
|
|
const appFiles = findTsxFiles(SRC_ROOT);
|
|
const violations: string[] = [];
|
|
|
|
for (const file of appFiles) {
|
|
if (file.includes("__tests__")) continue;
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
const basename = path.basename(rel);
|
|
|
|
if (ALLOWED_UNGUARDED.has(basename)) continue;
|
|
|
|
const isClientComponent = content.includes('"use client"') || content.includes("'use client'");
|
|
if (!isClientComponent) continue;
|
|
|
|
const lines = content.split("\n");
|
|
let insideGuard = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
|
|
if (line.includes("process.env.NODE_ENV") && line.includes("development")) {
|
|
insideGuard = true;
|
|
}
|
|
if (line === "}" || line === "};") {
|
|
insideGuard = false;
|
|
}
|
|
|
|
if (line.includes("console.error") || line.includes("console.warn")) {
|
|
if (!insideGuard && !line.includes("process.env.NODE_ENV")) {
|
|
const prevLine = i > 0 ? lines[i - 1].trim() : "";
|
|
const prevPrevLine = i > 1 ? lines[i - 2].trim() : "";
|
|
if (!prevLine.includes("process.env.NODE_ENV") && !prevPrevLine.includes("process.env.NODE_ENV")) {
|
|
violations.push(`${rel}:${i + 1}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found unguarded console.error/warn in client components:\n${violations.join("\n")}\nGuard with: if (process.env.NODE_ENV === 'development')`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 12. Environment variables: all process.env.XXX are documented
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Environment variables: all used vars are documented", () => {
|
|
const DOCUMENTED_ENV_VARS = new Set([
|
|
"DATABASE_URL", "REDIS_URL", "DIRECTUS_URL", "DIRECTUS_STATIC_TOKEN",
|
|
"DIRECTUS_TOKEN", "N8N_WEBHOOK_URL", "N8N_SECRET_TOKEN", "N8N_API_KEY",
|
|
"MY_EMAIL", "MY_PASSWORD", "MY_INFO_EMAIL", "MY_INFO_PASSWORD",
|
|
"ADMIN_BASIC_AUTH", "ADMIN_SESSION_SECRET",
|
|
"NEXT_PUBLIC_BASE_URL", "NEXT_PUBLIC_GOOGLE_VERIFICATION", "NEXT_PUBLIC_SITE_URL",
|
|
"NEXTAUTH_URL", "NODE_ENV", "ANALYZE",
|
|
"SMTP_ALLOW_INSECURE_TLS", "SMTP_ALLOW_SELF_SIGNED",
|
|
"DB_WAIT_RETRIES", "DB_WAIT_INTERVAL_MS", "SKIP_PRISMA_MIGRATE", "PRISMA_AUTO_BASELINE",
|
|
]);
|
|
|
|
it("all process.env references are documented in CLAUDE.md", () => {
|
|
const appFiles = findTsxFiles(SRC_ROOT);
|
|
const scriptFiles = findTsxFiles(path.join(ROOT, "scripts"), false);
|
|
const allFiles = [...appFiles, ...scriptFiles];
|
|
const envVars = new Set<string>();
|
|
|
|
const envRegex = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
|
|
|
|
for (const file of allFiles) {
|
|
if (file.includes("__tests__") || file.includes("jest.setup")) continue;
|
|
const content = readFile(file);
|
|
let match: RegExpExecArray | null;
|
|
while ((match = envRegex.exec(content)) !== null) {
|
|
envVars.add(match[1]);
|
|
}
|
|
envRegex.lastIndex = 0;
|
|
}
|
|
|
|
const undocumented = [...envVars].filter((v) => !DOCUMENTED_ENV_VARS.has(v));
|
|
if (undocumented.length > 0) {
|
|
throw new Error(`Undocumented env vars found: ${undocumented.join(", ")}\nAdd them to the DOCUMENTED_ENV_VARS set in this test and to CLAUDE.md`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 13. Code quality: no `any` type in app/ source (excluding tests/config)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Code quality: no `any` type in app/ source", () => {
|
|
it("no explicit `any` type annotations in app/ source files", () => {
|
|
const appFiles = findTsxFiles(SRC_ROOT);
|
|
const violations: string[] = [];
|
|
const anyTypeRegex = /:\s*any\b/g;
|
|
|
|
const ALLOWED_ANY_FILES = new Set([
|
|
"api/content/page/route.ts",
|
|
"api/fetchImage/route.tsx",
|
|
"components/KernelPanic404.tsx",
|
|
"editor/page.tsx",
|
|
]);
|
|
|
|
for (const file of appFiles) {
|
|
if (file.includes("__tests__")) continue;
|
|
if (file.includes("jest.setup")) continue;
|
|
const content = readFile(file);
|
|
const rel = path.relative(SRC_ROOT, file);
|
|
if (ALLOWED_ANY_FILES.has(rel)) continue;
|
|
const lines = content.split("\n");
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.includes("// eslint-disable-next-line")) continue;
|
|
if (line.includes("@ts-ignore") || line.includes("@ts-expect-error")) continue;
|
|
if (line.includes("as any") && line.includes("// eslint-disable")) continue;
|
|
|
|
if (anyTypeRegex.test(line) || line.includes("as any")) {
|
|
if (!line.includes("// eslint-disable")) {
|
|
violations.push(`${rel}:${i + 1} — ${line.trim()}`);
|
|
}
|
|
}
|
|
anyTypeRegex.lastIndex = 0;
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found ${violations.length} 'any' type usage(s) (CLAUDE.md: no 'any'):\n${violations.join("\n")}`);
|
|
}
|
|
});
|
|
}); |