0fa10315b2
- Tapping an object on the desk opens a window (desktop) or a bottom sheet (mobile) instead of flipping: projects, now, servers, reading, about, contact - Desktop windows in poster style (2px ink rule, hard offset shadow), open from the tapped object, thrown by the title bar with momentum and elastic viewport edges, stack by focus, minimize into a tray, Escape closes the top window, focus moves to close and returns to the trigger - Mobile sheets via vaul 1.1.2: drag down to dismiss, overlay, focus trap - Project window shows live/source links from Directus when present - Hero "Write me" opens the contact window; still a plain #contact link without JavaScript - Shared current-book loader for desk, deck and reading window - Texts in de/en, i18n key check extended Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
611 lines
30 KiB
TypeScript
611 lines
30 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: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] },
|
|
"home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "sticker"] },
|
|
"home.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] },
|
|
"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[^>]*>/g;
|
|
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", () => {
|
|
function lineContainsEmoji(line: string): boolean {
|
|
for (let i = 0; i < line.length; i++) {
|
|
const code = line.codePointAt(i);
|
|
if (code === undefined) continue;
|
|
if (code >= 0x1F300 && code <= 0x1FAFF) return true;
|
|
if (code >= 0x2600 && code <= 0x27BF) return true;
|
|
if (code >= 0xFE00 && code <= 0xFE0F) return true;
|
|
if (code === 0x200D) return true;
|
|
if (code === 0x20E3) return true;
|
|
if (code > 0xFFFF) i++;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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 (lineContainsEmoji(line)) {
|
|
const emojiChars: string[] = [];
|
|
for (let j = 0; j < line.length; j++) {
|
|
const code = line.codePointAt(j);
|
|
if (code !== undefined && ((code >= 0x1F300 && code <= 0x1FAFF) || (code >= 0x2600 && code <= 0x27BF) || (code >= 0xFE00 && code <= 0xFE0F) || code === 0x200D || code === 0x20E3)) {
|
|
emojiChars.push(String.fromCodePoint(code));
|
|
if (code > 0xFFFF) j++;
|
|
}
|
|
}
|
|
violations.push(`${rel}:${i + 1} — found emoji: ${emojiChars.join(", ")}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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")}`);
|
|
}
|
|
});
|
|
}); |