Files
portfolio/app/__tests__/pre-push-check.test.ts
denshooter 8c3c4ec1d0
CI / CD / test-build (push) Successful in 10m20s
CI / CD / deploy-dev (push) Successful in 1m26s
CI / CD / deploy-production (push) Has been skipped
fix: replace ES2018 regex flags in pre-push test for TS compat
2026-05-14 18:55:59 +02:00

609 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[^>]*>/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")}`);
}
});
});