import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { parse } from "@babel/parser"; export const getRuntimeModuleSpecifiers = (sourceText: string, fileName: string) => { try { const ast = parse(sourceText, { sourceFilename: fileName, sourceType: "module", plugins: ["typescript", "jsx"] }); return ast.program.body.flatMap((statement) => { if (statement.type === "ImportDeclaration") { if (statement.importKind === "type" || statement.importKind === "typeof") return []; const hasRuntimeBinding = statement.specifiers.length === 0 || statement.specifiers.some( (specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type", ); return hasRuntimeBinding ? [statement.source.value] : []; } if (statement.type === "ExportAllDeclaration") { return statement.exportKind === "type" ? [] : [statement.source.value]; } if (statement.type === "ExportNamedDeclaration" && statement.source) { if (statement.exportKind === "type") return []; const hasRuntimeBinding = statement.specifiers.length === 0 || statement.specifiers.some((specifier) => specifier.exportKind !== "type"); return hasRuntimeBinding ? [statement.source.value] : []; } return []; }); } catch (error) { throw new Error(`Unable to parse static imports in ${fileName}`, { cause: error }); } }; const canonicalize = (fileName: string) => { try { return fs.realpathSync.native(fileName); } catch { return path.resolve(fileName); } }; const resolveFile = (candidate: string) => { const candidates = path.extname(candidate) ? [candidate] : [ candidate, `${candidate}.ts`, `${candidate}.tsx`, `${candidate}.mts`, `${candidate}.js`, `${candidate}.jsx`, path.join(candidate, "index.ts"), path.join(candidate, "index.tsx"), path.join(candidate, "index.mts"), path.join(candidate, "index.js"), ]; return candidates.find((fileName) => fs.existsSync(fileName) && fs.statSync(fileName).isFile()) ?? null; }; const firstExportTarget = (value: unknown): string | null => { if (typeof value === "string") return value; if (!value || typeof value !== "object") return null; for (const nested of Object.values(value)) { const target = firstExportTarget(nested); if (target) return target; } return null; }; const getPackageName = (specifier: string) => specifier.split("/").slice(0, 2).join("/"); const resolvePackageExport = (exports: unknown, exportKey: string) => { if (exportKey === "." && typeof exports === "string") return exports; if (!exports || typeof exports !== "object") return null; const exportMap = exports as Record; const exact = firstExportTarget(exportMap[exportKey]); if (exact) return exact; for (const [pattern, value] of Object.entries(exportMap)) { const wildcard = pattern.indexOf("*"); if (wildcard < 0) continue; const prefix = pattern.slice(0, wildcard); const suffix = pattern.slice(wildcard + 1); if (!exportKey.startsWith(prefix) || !exportKey.endsWith(suffix)) continue; const match = exportKey.slice(prefix.length, exportKey.length - suffix.length); const target = firstExportTarget(value); if (target) return target.replaceAll("*", match); } return null; }; const getWorkspacePackages = (root: string) => { const packages = new Map(); for (const parent of ["apps", "packages", "tooling"]) { const parentDirectory = path.join(root, parent); if (!fs.existsSync(parentDirectory)) continue; for (const directoryEntry of fs.readdirSync(parentDirectory, { withFileTypes: true })) { if (!directoryEntry.isDirectory()) continue; const directory = path.join(parentDirectory, directoryEntry.name); const manifestPath = path.join(directory, "package.json"); if (!fs.existsSync(manifestPath)) continue; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { name?: string; exports?: unknown }; if (manifest.name) packages.set(manifest.name, { directory, exports: manifest.exports }); } } return packages; }; export const collectBoardStaticGraph = (repositoryRoot: string) => { const root = canonicalize(repositoryRoot); const entries = [ "apps/nextjs/src/app/[locale]/layout.tsx", "apps/nextjs/src/app/[locale]/(home)/(board)/layout.tsx", "apps/nextjs/src/app/[locale]/(home)/(board)/page.tsx", "apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/layout.tsx", "apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/page.tsx", "apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx", ].map((entry) => path.join(root, entry)); const workspacePackages = getWorkspacePackages(root); const unresolvedWorkspaceImports: Array<{ importer: string; specifier: string }> = []; const resolveSpecifier = (specifier: string, importer: string) => { if (specifier.startsWith(".")) return resolveFile(path.resolve(path.dirname(importer), specifier)); if (specifier.startsWith("~/")) return resolveFile(path.join(root, "apps/nextjs/src", specifier.slice(2))); if (specifier.startsWith("@static-data/")) { return resolveFile(path.join(root, "static-data", specifier.slice("@static-data/".length))); } if (!specifier.startsWith("@homarr/") || specifier === "@homarr/node-unifi") return null; const [, , ...subpathParts] = specifier.split("/"); const workspacePackage = workspacePackages.get(getPackageName(specifier)); if (!workspacePackage) return null; const exportKey = subpathParts.length === 0 ? "." : `./${subpathParts.join("/")}`; const target = resolvePackageExport(workspacePackage.exports, exportKey); return target ? resolveFile(path.resolve(workspacePackage.directory, target)) : null; }; const queue = [...entries]; const visited = new Set(); const parents = new Map(); const bareWidgetImports: Array<{ importer: string; specifier: string }> = []; const forbiddenModules = new Set([ path.join(root, "packages/widgets/index.ts"), path.join(root, "packages/widgets/src/index.tsx"), path.join(root, "packages/spotlight/src/component.ts"), path.join(root, "packages/spotlight/src/components/spotlight.tsx"), path.join(root, "packages/spotlight/src/spotlight-store.ts"), path.join(root, "packages/widgets/src/modals/widget-edit-modal.tsx"), path.join(root, "apps/nextjs/src/components/board/items/item-move-modal.tsx"), path.join(root, "apps/nextjs/src/components/onboarding/board-tour.tsx"), path.join(root, "apps/nextjs/src/components/onboarding/tour-shell.tsx"), ]); while (queue.length > 0) { const next = queue.shift(); if (!next) continue; const current = canonicalize(next); if (visited.has(current)) continue; visited.add(current); if (!/\.[cm]?[jt]sx?$/.test(current)) continue; const sourceText = fs.readFileSync(current, "utf8"); for (const specifier of getRuntimeModuleSpecifiers(sourceText, current)) { if (specifier === "@homarr/widgets") bareWidgetImports.push({ importer: current, specifier }); const resolved = resolveSpecifier(specifier, current); if (!resolved) { if (workspacePackages.has(getPackageName(specifier))) { unresolvedWorkspaceImports.push({ importer: current, specifier }); } continue; } const target = canonicalize(resolved); if (!target.startsWith(`${root}${path.sep}`) || target.includes(`${path.sep}node_modules${path.sep}`)) continue; if (!parents.has(target)) parents.set(target, { importer: current, specifier }); queue.push(target); } } const reachedForbiddenModules = [...forbiddenModules].filter((fileName) => visited.has(fileName)); const formatChain = (target: string) => { const chain = [path.relative(root, target)]; let current = target; while (parents.has(current)) { const parent = parents.get(current); if (!parent) break; chain.unshift(`${path.relative(root, parent.importer)} --${parent.specifier}-->`); current = parent.importer; } return chain.join(" "); }; return { entries: entries.map((entry) => path.relative(root, entry)), visitedModuleCount: visited.size, bareWidgetImports: bareWidgetImports.map(({ importer, specifier }) => ({ importer: path.relative(root, importer), specifier, })), reachedForbiddenModules: reachedForbiddenModules.map(formatChain), unresolvedWorkspaceImports: unresolvedWorkspaceImports.map(({ importer, specifier }) => ({ importer: path.relative(root, importer), specifier, })), }; }; const run = () => { const repositoryRoot = path.resolve(process.env.BOARD_GRAPH_ROOT ?? "."); const result = collectBoardStaticGraph(repositoryRoot); if ( result.bareWidgetImports.length > 0 || result.reachedForbiddenModules.length > 0 || result.unresolvedWorkspaceImports.length > 0 ) { throw new Error(`Board static graph reaches a forbidden eager module:\n${JSON.stringify(result, null, 2)}`); } console.log( JSON.stringify( { ...result, eagerWidgetRegistryReachable: false, eagerSpotlightImplementationReachable: false, eagerWidgetEditorReachable: false, eagerItemMoveEditorReachable: false, eagerBoardTourReachable: false, }, null, 2, ), ); }; if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) run();