import { readdir, readFile } from "node:fs/promises"; import { extname, join, relative, resolve } from "node:path"; const packageRoot = resolve(import.meta.dirname, ".."); const repositoryRoot = resolve(packageRoot, "../.."); const sourceRoots = [ join(packageRoot, "src"), join(repositoryRoot, "packages/api/src/router/custom-widget"), join(repositoryRoot, "packages/widgets/src/custom-api"), join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets"), ]; const productionFiles = []; async function collect(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name); if (entry.isDirectory()) { if (entry.name !== "test") await collect(path); } else if ([".ts", ".tsx"].includes(extname(entry.name)) && !/\.spec\.tsx?$/u.test(entry.name)) { productionFiles.push(path); } } } for (const sourceRoot of sourceRoots) await collect(sourceRoot); const failures = []; const graph = new Map(); const algorithmicExceptions = new Map([ ["packages/custom-widgets/src/jsx/analyzer.ts", 400], ["packages/custom-widgets/src/jsx/interpreter.tsx", 400], ]); for (const file of productionFiles) { const source = await readFile(file, "utf8"); const name = relative(repositoryRoot, file); const lineCount = source.trimEnd().split("\n").length; const limit = algorithmicExceptions.get(name) ?? 300; if (lineCount > limit) failures.push(`${name} has ${lineCount} lines (limit ${limit})`); if (file.startsWith(join(packageRoot, "src")) && /from\s+["']@homarr\/(?:api|widgets)(?:\/|["'])/u.test(source)) { failures.push(`${name} imports a forbidden adapter package`); } if (extname(file) === ".tsx" && /(?:runtime|workbench|custom-widgets|custom-api)/u.test(name)) { for (const line of source.split("\n")) { const match = line.match(/>\s*([A-Za-z][A-Za-z0-9 .,!?'-]*)\s* item === candidate || item === `${candidate}.ts` || item === `${candidate}.tsx` || item === join(candidate, "index.ts"), ); if (resolved) dependencies.push(resolved); } graph.set(file, dependencies); } const visiting = new Set(); const visited = new Set(); function visit(file, path = []) { if (visiting.has(file)) { failures.push(`circular dependency: ${[...path, file].map((item) => relative(repositoryRoot, item)).join(" -> ")}`); return; } if (visited.has(file)) return; visiting.add(file); for (const dependency of graph.get(file) ?? []) visit(dependency, [...path, file]); visiting.delete(file); visited.add(file); } for (const file of productionFiles) visit(file); const widgetAdapter = await readFile(join(repositoryRoot, "packages/widgets/src/custom-api/component.tsx"), "utf8"); if (!/dynamic\(\(\)\s*=>\s*import\(["']\.\/custom-jsx-display["']\)/u.test(widgetAdapter)) { failures.push("Custom JSX board rendering must remain dynamically loaded"); } const editorAdapter = await readFile( join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"), "utf8", ); if (!/lazy\(\(\)\s*=>\s*import\(["'].\/direct-code-mirror["']\)\)/u.test(editorAdapter)) { failures.push("CodeMirror must remain dynamically loaded after the client mounts"); } const componentRegistry = await readFile( join(repositoryRoot, "packages/custom-widgets/src/core/component-registry.ts"), "utf8", ); if (!/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) { failures.push("The Custom JSX runtime registry must use the canonical authoring catalog"); } if (/component-runtime\.generated\.json/u.test(componentRegistry)) { failures.push("The Custom JSX runtime registry must not use a second generated component catalog"); } if (failures.length > 0) { console.error(failures.join("\n")); process.exitCode = 1; } else { console.log(`Custom Widgets architecture checks passed for ${productionFiles.length} production modules.`); }