plugins_analyze_circular.js

/**
 * @file plugins/analyze/circular.js
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *//** */

const { relative } = require("path");
const WpwAnalyzePlugin = require("./base");
const { isNulled } = require("@spmhome/type-utils");


class WpwCircularAnalyzePlugin extends WpwAnalyzePlugin
{
    allowAsyncCycles
    /**
     * @type {RegExp}
     */
    include;

    /**
     * @type {RegExp}
     */
    exclude;


    /**
     * @param {WpwPluginOptions} options Plugin options to be applied
     */
	constructor(options)
	{
		super(options);
        this.exclude = /node_modules/;
        this.include = new RegExp(this.build.getSrcPath());
        // this.include = new RegExp(escapeRegExp(this.build.getSrcPath()));
        this.buildOptions = /** @type {WpwCircularAnalyzePluginOptions} */(this.buildOptions);
	}


	/**
     * @override
     */
	static create = WpwCircularAnalyzePlugin.wrap.bind(this);


    /**
     * @override
     * @returns {WpwPluginTapOptions<any, any, boolean> | undefined}
     */
    onApply()
    {
        return {
            detectCircularDependencies: {
                async: false,
                forceRun: true,
                hook: "compilation",
                // stage: "REPORT",
                hookCompilation: "optimizeModules",
                callback: this.detectCircularDependencies.bind(this)
            }
        };
    }


    // /**
    //  * @param {Iterable<WebpackModule>} modules
    //  */
    // detectCircularDependencies(modules)
    // {
    //     const l = this.hookstart();
    //     try
    //     {   const fModules = modules.filter(/** @param {WebpackModule & {resource?: never} | WebpackNormalModule} m */
    //             (m) => m.resource && !this.exclude.test(m.resource) && (!this.include || this.include.test(m.resource))
    //         );
    //         fModules.map(/** @param { WebpackNormalModule } m */
    //             (m) => { const paths = this.isCyclic(m, m, {}); if (paths) { return { mod: m, paths }; }}
    //         )
    //         .forEach((/** @type {{ mod: WebpackNormalModule, paths: string[] }}*/d) =>
    //         {   l.write(`  detected circular dependency in path sequence ${d.paths.join(" -> ")}`, 1);
    //             this.addMessage({
    //                 message: d.paths.join(" -> "),
    //                 code: !!this.buildOptions.fail ? SpmhError.Code.ERROR_CIRCULAR_DEP : SpmhError.Code.WARNING_CIRCULAR_DEP
    //             });
    //         });
    //     }
    //     catch (e)
    //     {   this.addMessage(
    //         {   exception: e,
    //             message: "an exception was thrown while trying to detect circular module dependencies",
    //             code: !!this.buildOptions.fail ? SpmhError.Code.ERROR_CIRCULAR_DEP : SpmhError.Code.WARNING_CIRCULAR_DEP
    //         });
    //     }
    //     finally { this.hookdone(); }
    // }


    // /**
    //  * @param {WebpackNormalModule} ogMod
    //  * @param {WebpackNormalModule} curMod
    //  * @param {Record<number, boolean>} seenModules
    //  * @returns {string[] | void}
    //  */
    // isCyclic(ogMod, curMod, seenModules)
    // {
    //     const baseDir = this.build.getBasePath(),
    //           cjsSelfRefDsc = "CommonJsSelfReferenceDependency";
    //     seenModules[curMod.debugId] = true;
    //     if (curMod.resource && ogMod.resource)
    //     {
    //         for (const d of curMod.dependencies.filter((d) => d.constructor && d.constructor.name !== cjsSelfRefDsc))
    //         {
    //             const depModule = this.compilation.moduleGraph ? this.compilation.moduleGraph.getModule(d) : d.module;
    //             if (!depModule) { continue; }
    //             if (curMod === depModule) { continue; }
    //             if (depModule.debugId in seenModules)
    //             {   if (depModule.debugId === ogMod.debugId) {
    //                     return [ relative(baseDir, curMod.resource), relative(baseDir, depModule.resource) ];
    //                 }
    //                 continue;
    //             }
    //             const cyc = this.isCyclic(ogMod, depModule, seenModules);
    //             if (cyc) { cyc.unshift(relative(baseDir, curMod.resource)); return cyc; }
    //         }
    //     }
    // }


    detectCircularDependencies(modules)
    {
        for (const module of modules)
        {   if (!isNulled(module.resource) && !this.exclude.test(module.resource) && this.include.test(module.resource))
            {   const maybeCyclicalPathsList = this.isCyclic2(module, module, {}, this.compilation);
                if (maybeCyclicalPathsList)
                {   this.build.addMessage({
                        plugin: "analyze|circular",
                        message: maybeCyclicalPathsList.join(" -> "),
                        code: this.buildOptions.fail ? this.MsgCode.ERROR_CIRCULAR_DEP : this.MsgCode.WARNING_CIRCULAR_DEP
                    });
                }
            }
        }
    }


    isCyclic2(initialModule, currentModule, seenModules, compilation)
    {
        const cwd = this.build.getBasePath();
        seenModules[currentModule.debugId] = true;
        if (!currentModule.resource || !initialModule.resource) { return false; }
        for (const dependency of currentModule.dependencies)
        {   if (dependency.constructor && dependency.constructor.name === this) { continue; }
            const depModule = compilation.moduleGraph ?
                  /* webpack5 */ compilation.moduleGraph.getModule(dependency) :
                  /* webpack4 */ dependency.module;
            if (!depModule) { continue; }
            if (!depModule.resource) { continue; } // ignore dependencies that don't have an associated resource
            if (this.allowAsyncCycles && dependency.weak) { continue; } // ignore deps that resolve asynchronously
            if (currentModule === depModule) { continue; }       // the dependency was resolved to the current module
            if (depModule.debugId in seenModules)                // due to how webpack internals setup deps e.g.
            {   if (depModule.debugId === initialModule.debugId) // CommonJsSelfReferenceDependency/ModuleDecoratorDependency
                {   return [
                        relative(cwd, currentModule.resource),
                        relative(cwd, depModule.resource)
                    ];
                } continue; // Found a cycle, but not for this module
            }
            const maybeCyclicalPathsList = this.isCyclic2(initialModule, depModule, seenModules, this.compilation);
            if (maybeCyclicalPathsList) {
                maybeCyclicalPathsList.unshift(relative(cwd, currentModule.resource));
                return maybeCyclicalPathsList;
            }
        }

        return false;
    }
}


module.exports = WpwCircularAnalyzePlugin.create;