UNPKG

rollup-plugin-circular-dependencies

Version:
521 lines (510 loc) 19.2 kB
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: 'Module' } }); //#region \0rolldown/runtime.js var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) { __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } } } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion let _rollup_pluginutils = require("@rollup/pluginutils"); let node_path = require("node:path"); node_path = __toESM(node_path); let node_process = require("node:process"); node_process = __toESM(node_process); let node_util = require("node:util"); let node_fs = require("node:fs"); //#region src/constants.ts /** Plugin name used in Rollup plugin system */ const PLUGIN_NAME = "circular-dependencies"; /** Default file extensions to include in circular dependency checks */ const DEFAULT_INCLUDE = [/\.(c|m)?[jt]s(x)?$/]; /** Default patterns to exclude from circular dependency checks */ const DEFAULT_EXCLUDE = [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/]; /** Rollup log code for built-in circular dependency warnings */ const ROLLUP_CIRCULAR_DEPENDENCY_CODE = "CIRCULAR_DEPENDENCY"; //#endregion //#region src/utils/formatters.ts function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Creates a JSON formatter that serializes data with 2-space indentation. * * @returns Formatter function producing JSON string output * * @example * ```ts * circularDependencies({ * formatOut: DefaultFormatters.JSON() * }) * ``` */ function JSONFormatter() { return (data) => { if (!Array.isArray(data) && !isObject(data)) return data ?? ""; return JSON.stringify(data, null, 2); }; } function isColorDisabled() { return node_process.default.env.NO_COLOR === "1" || node_process.default.env.NO_COLOR === "true" || node_process.default.env.NO_COLOR === "TRUE" || node_process.default.env.NODE_DISABLE_COLORS === "1" || node_process.default.env.NODE_DISABLE_COLORS === "true" || node_process.default.env.NODE_DISABLE_COLORS === "TRUE" || node_process.default.env.FORCE_COLOR === "0" || node_process.default.env.FORCE_COLOR === "false" || node_process.default.env.FORCE_COLOR === "FALSE"; } /** * Creates a human-readable pretty formatter with optional color support. * Colors are enabled by default unless disabled via environment variables * (`NO_COLOR`, `NODE_DISABLE_COLORS`, `FORCE_COLOR=0`). * * @param config - Optional configuration for color output * @returns Formatter function producing styled string output * * @example * ```ts * circularDependencies({ * formatOut: DefaultFormatters.Pretty({ colors: false }) * }) * ``` */ function PrettyFormatter(config) { const { colors = !isColorDisabled() } = config || {}; return (data) => { if (isObject(data) && Object.keys(data).length === 0) return ""; if (!isValidData(data)) throw new Error("Pretty formatter can only be used with original output"); const groups = []; for (const [entryModuleId, moduleNodes] of Object.entries(data)) { let group = ""; group += colors ? (0, node_util.styleText)("yellow", entryModuleId) : entryModuleId; for (const currentCir of moduleNodes) group += `\n ${currentCir.join(colors ? (0, node_util.styleText)("blue", " -> ") : " -> ")}`; groups.push(group); } return groups.join("\n\n"); }; } /** * Validates that data conforms to CircularDependenciesData structure. * Checks all entries, not just the first one, for thorough validation. */ function isValidData(data) { if (!data || !isObject(data)) return false; return Object.values(data).every((value) => { if (!Array.isArray(value)) return false; return value.every((innerValue) => { return Array.isArray(innerValue) && innerValue.length > 0 && innerValue.every((item) => typeof item === "string"); }); }); } const DefaultFormatters = { JSON: JSONFormatter, Pretty: PrettyFormatter }; //#endregion //#region src/utils/moduleTree.ts /** * Builds the module dependency tree by resolving imported IDs * into actual ModuleNode references stored as children. */ function generateModuleTree(ctx, rootModuleNode) { const visited = /* @__PURE__ */ new Set(); const moduleNodes = ctx.moduleNodes; function resolveChildren(node) { return node.importedIds.map((id) => moduleNodes.get(id)).filter((n) => n !== void 0); } function buildTree(node) { visited.add(node.id); const children = resolveChildren(node); for (const child of children) { if (!visited.has(child.id)) buildTree(child); node.children.add(child); } } buildTree(rootModuleNode); } /** * Detects all circular dependencies using Tarjan's algorithm * for finding strongly connected components (SCCs). * Runs in O(V + E) time complexity. */ function generateCycleNodesMap(ctx, rootModuleNode, pluginContext) { const cycleNodesMap = /* @__PURE__ */ new Map(); const sccs = findStronglyConnectedComponents(rootModuleNode); for (const scc of sccs) { if (scc.length === 1) { const node = scc[0]; if (!node.children.has(node)) continue; } for (const node of scc) ctx.options.onDetected(node.id, pluginContext); const cycleId = normalizeCycleId(scc); cycleNodesMap.set(cycleId, normalizeCycleNodes(scc)); } return cycleNodesMap; } /** Creates a deterministic ID for a cycle by sorting node IDs */ function normalizeCycleId(nodes) { return nodes.map((node) => node.id).sort().join("-"); } /** Returns a sorted copy of cycle nodes for consistent output */ function normalizeCycleNodes(nodes) { return [...nodes].sort((a, b) => a.id.localeCompare(b.id)); } /** * Tarjan's algorithm for finding all strongly connected components. * Each SCC with more than one node (or a self-referencing single node) * represents a circular dependency. * * @see https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm */ function findStronglyConnectedComponents(root) { let index = 0; const stack = []; const onStack = /* @__PURE__ */ new Set(); const indices = /* @__PURE__ */ new Map(); const lowLinks = /* @__PURE__ */ new Map(); const result = []; function strongConnect(node) { indices.set(node.id, index); lowLinks.set(node.id, index); index++; stack.push(node); onStack.add(node.id); for (const child of node.children) if (!indices.has(child.id)) { strongConnect(child); lowLinks.set(node.id, Math.min(lowLinks.get(node.id), lowLinks.get(child.id))); } else if (onStack.has(child.id)) lowLinks.set(node.id, Math.min(lowLinks.get(node.id), indices.get(child.id))); if (lowLinks.get(node.id) === indices.get(node.id)) { const scc = []; let current; do { current = stack.pop(); onStack.delete(current.id); scc.push(current); } while (current !== node); result.push(scc); } } strongConnect(root); return result; } //#endregion //#region src/utils/printers.ts /** Serializes formatted data to a string */ function serialize(data, colors) { if (data === null || data === void 0) return ""; if (typeof data === "string") return data; return (0, node_util.inspect)(data, { depth: null, colors }); } var Printer = class { ctx; constructor(params) { this.ctx = params.context; } }; var FilePrinter = class extends Printer { shouldPrint() { return Boolean(this.ctx.options.outputFilePath); } print(data) { const resultData = serialize(data, false); const filePath = this.ctx.options.outputFilePath; const dirPath = (0, node_path.dirname)(filePath); try { if (!(0, node_fs.existsSync)(dirPath)) (0, node_fs.mkdirSync)(dirPath, { recursive: true }); (0, node_fs.writeFileSync)(filePath, resultData, { encoding: "utf-8" }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`[circular-dependencies] Failed to write output file "${filePath}": ${message}`); } } }; var ConsolePrinter = class extends Printer { shouldPrint(data) { const isConsolePrinter = !this.ctx.options.outputFilePath; const isDataExists = typeof data === "string" ? data.length > 0 : data !== null && data !== void 0; return isConsolePrinter && isDataExists; } print(data) { const output = serialize(data, true); if (!output) return; console.info(`\n\n${output}\n`); } }; function createPrinters(params) { return [new FilePrinter(params), new ConsolePrinter(params)]; } //#endregion //#region src/utils/print.ts /** * Processes detected cycles: formats, filters, prints, and validates results. * Collects metrics and invokes lifecycle callbacks. */ function printCycleNodes(ctx, cycleNodes, pluginContext, detectionStartTime) { const filteredData = applyIgnoreFilter(ctx, buildRawData(cycleNodes)); const transformedData = transformNodePaths(ctx, filteredData); const formattedData = ctx.options.formatOut(transformedData); const metrics = collectMetrics(ctx, filteredData, detectionStartTime); if (ctx.options.debug) logDebugInfo(pluginContext, metrics, filteredData); ctx.options.onEnd({ rawOutput: filteredData, formattedOutput: formattedData, metrics }, pluginContext); for (const printer of createPrinters({ context: ctx })) if (printer.shouldPrint(formattedData)) printer.print(formattedData); validateCycleData(pluginContext, ctx, metrics.cyclesFound); } function buildRawData(data) { return groupByFirstNodePath(getNodePaths(filterEmptyNodes(data))); } function filterEmptyNodes(cycleNodesMap) { return Array.from(cycleNodesMap.values()).filter((item) => item.length > 0); } function getNodePaths(data) { return data.map((nodeArr) => nodeArr.map((item) => item.id)); } function groupByFirstNodePath(data) { return data.reduce((acc, curNodes) => { const firstNodeId = curNodes[0]; acc[firstNodeId] ??= []; acc[firstNodeId].push(curNodes); return acc; }, {}); } function applyIgnoreFilter(ctx, data) { const { ignoreCycle } = ctx.options; const filtered = {}; for (const [nodeId, cycles] of Object.entries(data)) { const remainingCycles = cycles.filter((cyclePaths) => !ignoreCycle(cyclePaths)); if (remainingCycles.length > 0) filtered[nodeId] = remainingCycles; } return filtered; } function transformNodePaths(ctx, data) { const transformedData = {}; for (const [nodeId, cycles] of Object.entries(data)) { const transformedKey = ctx.options.formatOutModulePath(nodeId); transformedData[transformedKey] = cycles.map((cycle) => { return cycle.map((id) => ctx.options.formatOutModulePath(id)); }); } return transformedData; } function collectMetrics(ctx, data, detectionStartTime) { const allCycles = Object.values(data).flat(); const largestCycleSize = allCycles.reduce((max, cycle) => Math.max(max, cycle.length), 0); return { modulesChecked: ctx.moduleNodes.size, cyclesFound: allCycles.length, largestCycleSize, detectionTimeMs: performance.now() - detectionStartTime }; } function logDebugInfo(pluginContext, metrics, data) { pluginContext.info(`[circular-dependencies] Checked ${metrics.modulesChecked} modules in ${metrics.detectionTimeMs.toFixed(1)}ms. Found ${metrics.cyclesFound} cycle(s), largest cycle size: ${metrics.largestCycleSize}.`); for (const [nodeId, cycles] of Object.entries(data)) for (const cycle of cycles) pluginContext.info(`[circular-dependencies] Cycle: ${nodeId}${cycle.join(" → ")}`); } function validateCycleData(pluginContext, ctx, cycleCount) { if (cycleCount === 0) return; (ctx.options.throwOnError ? pluginContext.error : pluginContext.warn)(`Circular dependencies detected: ${cycleCount} cycle(s) found`); } //#endregion //#region src/context/formatOptions.ts /** * Normalizes and validates user-provided options into a complete configuration. * Applies default values for all optional fields. * * @param options - Partial options provided by the user * @returns Fully resolved options with all defaults applied * @throws {Error} When option values are invalid */ function formatOptions(options) { validateOptions(options); const { enabled = true, include = DEFAULT_INCLUDE, throwOnError = true, debug = false, formatOutModulePath, formatOut, ignoreCycle, onStart, onDetected, onEnd } = options; let { outputFilePath = "" } = options; const exclude = options.exclude !== void 0 ? normalizeExcludePattern(options.exclude) : DEFAULT_EXCLUDE; outputFilePath = normalizeOutputFilePath(outputFilePath); return { enabled, include, exclude, throwOnError, debug, outputFilePath, formatOutModulePath: formatOutModulePath ?? defaultFormatOutModulePath, formatOut: formatOut ?? getDefaultFormatOut(outputFilePath), ignoreCycle: ignoreCycle ?? (() => false), onStart: onStart ?? (() => void 0), onDetected: onDetected ?? (() => void 0), onEnd: onEnd ?? (() => void 0) }; } function validateOptions(options) { if (options.outputFilePath !== void 0 && typeof options.outputFilePath !== "string") throw new Error(`[circular-dependencies] "outputFilePath" must be a string`); if (options.formatOutModulePath !== void 0 && typeof options.formatOutModulePath !== "function") throw new Error(`[circular-dependencies] "formatOutModulePath" must be a function`); if (options.formatOut !== void 0 && typeof options.formatOut !== "function") throw new Error(`[circular-dependencies] "formatOut" must be a function`); if (options.ignoreCycle !== void 0 && typeof options.ignoreCycle !== "function") throw new Error(`[circular-dependencies] "ignoreCycle" must be a function`); if (options.onStart !== void 0 && typeof options.onStart !== "function") throw new Error(`[circular-dependencies] "onStart" must be a function`); if (options.onDetected !== void 0 && typeof options.onDetected !== "function") throw new Error(`[circular-dependencies] "onDetected" must be a function`); if (options.onEnd !== void 0 && typeof options.onEnd !== "function") throw new Error(`[circular-dependencies] "onEnd" must be a function`); } function normalizeOutputFilePath(outputFilePath) { if (outputFilePath) return node_path.default.isAbsolute(outputFilePath) ? outputFilePath : node_path.default.join(node_process.default.cwd(), outputFilePath); return outputFilePath; } function normalizeExcludePattern(exclude) { if (Array.isArray(exclude)) return [...exclude, ...DEFAULT_EXCLUDE]; if (exclude) return [exclude, ...DEFAULT_EXCLUDE]; return DEFAULT_EXCLUDE; } function defaultFormatOutModulePath(outputFilePath) { return node_path.default.relative(node_process.default.cwd(), outputFilePath); } function getDefaultFormatOut(outputFilePath) { const formatter = outputFilePath ? DefaultFormatters.JSON() : DefaultFormatters.Pretty(); return (data) => formatter(data); } //#endregion //#region src/context/context.ts /** * Internal plugin state container. * Holds resolved options, module graph, and entry point reference. * Supports cleanup between builds for watch mode compatibility. */ var Context = class { options; shouldProcessModule; moduleNodes; _entryModuleNode = null; constructor(options) { this.options = formatOptions(options); this.shouldProcessModule = (0, _rollup_pluginutils.createFilter)(this.options.include, this.options.exclude); this.moduleNodes = /* @__PURE__ */ new Map(); } /** Returns the entry module node (first parsed module) */ get entryModuleNode() { return this._entryModuleNode; } /** Sets the entry module node. Only the first assignment takes effect per build cycle. */ setEntryModuleNode(moduleNode) { if (!this._entryModuleNode) this._entryModuleNode = moduleNode; } /** * Resets internal state between build cycles. * Essential for watch mode to prevent stale data and memory leaks. */ reset() { this._entryModuleNode = null; this.moduleNodes.clear(); } }; //#endregion //#region src/module/ModuleNode.ts var ModuleNode = class { id; importedIds; children = /* @__PURE__ */ new Set(); constructor(moduleInfo) { this.id = moduleInfo.id; this.importedIds = Object.freeze([...moduleInfo.importedIds, ...moduleInfo.dynamicallyImportedIds]); } }; //#endregion //#region src/index.ts /** * Creates a Rollup plugin that detects circular dependencies in your project. * Works with both Rollup and Vite. * * @param options - Plugin configuration options * @returns Rollup plugin instance * * @example * ```ts * // Basic usage * * import circularDependencies from 'rollup-plugin-circular-dependencies'; * * export default { * plugins: [circularDependencies()] * } * ``` * * @example * ```ts * // Advanced usage * * import circularDependencies, { DefaultFormatters } from 'rollup-plugin-circular-dependencies'; * * export default { * plugins: [ * circularDependencies({ * enabled: true, * exclude: [/node_modules/], * throwOnError: true, * debug: false, * formatOut: DefaultFormatters.Pretty({ colors: false }), * ignoreCycle: (paths) => paths.some(p => p.includes('generated')), * onStart: (pluginContext) => {}, * onDetected: (modulePath, pluginContext) => {}, * onEnd: ({ rawOutput, formattedOutput, metrics }, pluginContext) => { * console.log(`Found ${metrics.cyclesFound} cycle(s)`); * }, * }) * ] * } * ``` */ function circularDependencies(options = {}) { const context = new Context(options); return { name: PLUGIN_NAME, options(inputOptions) { const savedOnLog = inputOptions.onLog; inputOptions.onLog = (level, log, defaultHandler) => { if (log.code === ROLLUP_CIRCULAR_DEPENDENCY_CODE) return; if (savedOnLog) savedOnLog(level, log, defaultHandler); else defaultHandler(level, log); }; }, buildStart() { context.reset(); }, moduleParsed(moduleInfo) { if (!context.options.enabled || !context.shouldProcessModule(moduleInfo.id)) return; const moduleNode = new ModuleNode(moduleInfo); context.setEntryModuleNode(moduleNode); context.moduleNodes.set(moduleInfo.id, moduleNode); }, generateBundle() { const pluginContext = this; if (!context.entryModuleNode) { if (context.options.enabled) pluginContext.info("No files to check. Check the \"include\" or \"exclude\" pattern in the \"circular-dependencies\" plugin options."); return; } const detectionStartTime = performance.now(); context.options.onStart(pluginContext); generateModuleTree(context, context.entryModuleNode); printCycleNodes(context, generateCycleNodesMap(context, context.entryModuleNode, pluginContext), pluginContext, detectionStartTime); } }; } //#endregion exports.DefaultFormatters = DefaultFormatters; exports.circularDependencies = circularDependencies; exports.default = circularDependencies;