rollup-plugin-circular-dependencies
Version:
Detect circular dependencies in Rollup projects
521 lines (510 loc) • 19.2 kB
JavaScript
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;