diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65698e3..6223620 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,11 @@ jobs: - name: Install dependencies run: npm ci + # Runs before the build so a broken analysis fails the run without + # producing a Pages artifact from it. + - name: Test + run: npm test + - name: Build run: npm run build diff --git a/README.md b/README.md index 8b2b486..6303124 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This project is a lightweight front-end viewer for webpack output generated with - Module dependency graph and chunk relationships - Asset and bundle size breakdowns - Warning and error inspection -- Hints for common optimization issues +- Hints for common optimization issues, including circular dependencies - Upload a generated stats file directly in the app ## Requirements @@ -75,6 +75,16 @@ Deployment is automatic: every push to `master` builds the site and publishes it to GitHub Pages via `.github/workflows/ci.yml`. Pull requests build but never publish. There is no manual deploy step. +```bash +npm test +``` + +Runs the unit tests in [`test/`](test). They check the circular dependency +detection against [`app/pages/upload/example3.json`](app/pages/upload/example3.json), +a small hand-written stats file that covers every hint of the hints page and is +loadable in the app as the "hint test cases" example. CI runs this before the +build, so a failing test stops the run and nothing is deployed. + ## Typical workflow 1. Build your application with webpack in profiling mode. diff --git a/app/findCircularDependencies.js b/app/findCircularDependencies.js new file mode 100644 index 0000000..a282f3f --- /dev/null +++ b/app/findCircularDependencies.js @@ -0,0 +1,203 @@ +// Finds circular dependencies in the module graph. +// +// Only modules inside a strongly connected component can be part of a cycle, +// so the search is limited to the non-trivial components found by Tarjan's +// algorithm. Inside a component every elementary circuit is enumerated from +// its lowest module, which reports each circuit exactly once. The number of +// circuits can grow exponentially with the size of a component, so both the +// number of reported cycles and the search effort are capped. + +// Reasons a module lists for referring to itself, e.g. `exports` in a +// CommonJS module. They are not dependencies on another module and would +// otherwise turn a large part of the graph into one-module cycles. +var SELF_REFERENCE_TYPES = { + "cjs self exports reference": true, + "module decorator": true +}; + +var DEFAULT_MAX_CYCLES = 4; +var DEFAULT_MAX_STEPS = 10000; + +// Both algorithms below are written with an explicit stack instead of +// recursion, because the graph can be deeper than the JS call stack allows. + +function buildAdjacency(modules) { + var nodeByUid = {}; + modules.forEach(function(module, node) { + if (typeof module.uid === "number") nodeByUid[module.uid] = node; + }); + return modules.map(function(module) { + var edges = []; + var seen = {}; + (module.dependencies || []).forEach(function(dependency) { + if (SELF_REFERENCE_TYPES[dependency.type] === true) return; + var node = nodeByUid[dependency.moduleUid]; + // Multiple references to the same module are a single edge here, + // the first one is kept to point at the source location. + if (typeof node !== "number" || seen[node]) return; + seen[node] = true; + edges.push({ node: node, dependency: dependency }); + }); + return edges; + }); +} + +function findStronglyConnectedComponents(adjacency) { + var index = []; + var lowlink = []; + var onStack = []; + var componentOfNode = []; + var stack = []; + var components = []; + var nextIndex = 0; + adjacency.forEach(function(_, root) { + if (typeof index[root] === "number") return; + var work = [{ node: root, edge: 0 }]; + while (work.length > 0) { + var frame = work[work.length - 1]; + var node = frame.node; + if (frame.edge === 0) { + index[node] = lowlink[node] = nextIndex++; + onStack[node] = true; + stack.push(node); + } + var edges = adjacency[node]; + var descended = false; + while (frame.edge < edges.length) { + var next = edges[frame.edge++].node; + if (typeof index[next] !== "number") { + work.push({ node: next, edge: 0 }); + descended = true; + break; + } + if (onStack[next] && index[next] < lowlink[node]) + lowlink[node] = index[next]; + } + if (descended) continue; + if (lowlink[node] === index[node]) { + var component = []; + var member; + do { + member = stack.pop(); + onStack[member] = false; + componentOfNode[member] = components.length; + component.push(member); + } while (member !== node); + components.push( + component.sort(function(a, b) { + return a - b; + }) + ); + } + work.pop(); + if (work.length > 0) { + var parent = work[work.length - 1].node; + if (lowlink[node] < lowlink[parent]) lowlink[parent] = lowlink[node]; + } + } + }); + return { components: components, componentOfNode: componentOfNode }; +} + +// Enumerates the elementary circuits of one component. A circuit is only +// reported when it is entered through its lowest module, so rotations of the +// same circuit are not reported twice. +function findCyclesInComponent(adjacency, componentOfNode, component, state) { + var componentId = componentOfNode[component[0]]; + for (var i = 0; i < component.length; i++) { + var start = component[i]; + var path = [start]; + var takenEdges = []; + var nextEdge = [0]; + var onPath = {}; + onPath[start] = true; + while (path.length > 0) { + var depth = path.length - 1; + var node = path[depth]; + var edges = adjacency[node]; + var descended = false; + while (nextEdge[depth] < edges.length) { + if (state.steps++ >= state.maxSteps) { + state.truncated = true; + return; + } + var edge = edges[nextEdge[depth]++]; + var next = edge.node; + // Modules below the start are covered by an earlier start, and + // modules of other components can never lead back here. + if (next < start || componentOfNode[next] !== componentId) continue; + if (next === start) { + state.cycles.push({ + nodes: path.slice(), + dependencies: takenEdges.slice(0, depth).concat(edge.dependency) + }); + // One cycle more than requested is collected to know for sure + // that there are more cycles than the reported ones. + if (state.cycles.length > state.maxCycles) { + state.truncated = true; + return; + } + continue; + } + if (onPath[next]) continue; + takenEdges[depth] = edge.dependency; + path.push(next); + nextEdge.push(0); + onPath[next] = true; + descended = true; + break; + } + if (descended) continue; + delete onPath[node]; + path.pop(); + nextEdge.pop(); + } + } +} + +module.exports = function findCircularDependencies(modules, options) { + options = options || {}; + var adjacency = buildAdjacency(modules); + var scc = findStronglyConnectedComponents(adjacency); + var state = { + cycles: [], + steps: 0, + truncated: false, + maxCycles: options.maxCycles || DEFAULT_MAX_CYCLES, + maxSteps: options.maxSteps || DEFAULT_MAX_STEPS + }; + var moduleCount = 0; + var componentCount = 0; + scc.components.forEach(function(component) { + var isCyclic = + component.length > 1 || + adjacency[component[0]].some(function(edge) { + return edge.node === component[0]; + }); + if (!isCyclic) return; + moduleCount += component.length; + componentCount++; + if (state.truncated) return; + findCyclesInComponent(adjacency, scc.componentOfNode, component, state); + }); + var cycles = state.cycles.slice(0, state.maxCycles).map(function(cycle) { + return { + modules: cycle.nodes.map(function(node) { + return modules[node]; + }), + dependencies: cycle.dependencies + }; + }); + // Short cycles are the easiest ones to understand and to break. + cycles.sort(function(a, b) { + if (a.modules.length !== b.modules.length) + return a.modules.length - b.modules.length; + return a.modules[0].uid - b.modules[0].uid; + }); + return { + cycles: cycles, + truncated: state.truncated, + moduleCount: moduleCount, + componentCount: componentCount + }; +}; diff --git a/app/pages/hints/hints.pug b/app/pages/hints/hints.pug index 11c4844..1c39a70 100644 --- a/app/pages/hints/hints.pug +++ b/app/pages/hints/hints.pug @@ -1,8 +1,46 @@ .container-fluid .row .col-md-12 - if multiChunks.length === 0 && multiRefs.length === 0 && longChains.length === 0 + if circularDependencies.cycles.length === 0 && multiChunks.length === 0 && multiRefs.length === 0 && longChains.length === 0 h2 Everything seem to be fine. + if circularDependencies.cycles.length > 0 + h2 Circular dependencies + p These modules require each other in a loop. One of them is evaluated before the module it depends on has finished evaluating, so it may read an export that is still undefined. Break the loop by moving the shared part into a module of its own, or by moving the access to the export into a function that runs later. + p + = `${circularDependencies.moduleCount} modules are part of ${circularDependencies.componentCount} group${circularDependencies.componentCount === 1 ? "" : "s"} of modules that (indirectly) require each other.` + if circularDependencies.truncated + = ` Only the first ${circularDependencies.cycles.length} cycles are listed, fix these and check again.` + for cycle in circularDependencies.cycles + table.table.table-bordered + thead + tr + th module + th name + th type + th user request + th location + th requires + tbody + for module, idx in cycle.modules + - var dependency = cycle.dependencies[idx] + - var next = cycle.modules[(idx + 1) % cycle.modules.length] + tr + td + if typeof module.uid === "number" + a.btn.btn-success(href=`#module/${module.uid}`)= module.id + else + span.btn.btn-success= module.id + td: pre: code= module.name.split("!").join("\n") + td= dependency.type + td: if dependency.userRequest + pre: code= dependency.userRequest.split("!").join("\n") + td: if dependency.loc + code= dependency.loc + td + if typeof next.uid === "number" + a.btn.btn-success(href=`#module/${next.uid}`)= next.id + else + span.btn.btn-success= next.id if multiChunks.length > 0 h2 Module in multiple chunks p Check if it is a good idea to move modules into a common parent. You may want to use require.include or insert them into the parents require.ensure array. diff --git a/app/pages/hints/page.js b/app/pages/hints/page.js index b290a26..35c8770 100644 --- a/app/pages/hints/page.js +++ b/app/pages/hints/page.js @@ -1,5 +1,6 @@ var app = require("../../app"); var findById = require("../../findById"); +var findCircularDependencies = require("../../findCircularDependencies"); module.exports = function() { document.title = "hints"; @@ -31,6 +32,8 @@ module.exports = function() { return b.saving - a.saving; }); + var circularDependencies = findCircularDependencies(app.stats.modules); + var multiChunks = []; app.stats.modules.forEach(function(module) { if (module.chunks.length >= 2) { @@ -70,6 +73,7 @@ module.exports = function() { $(".page").html( require("./hints.pug")({ stats: app.stats, + circularDependencies: circularDependencies, multiRefs: multiRefs, multiChunks: multiChunks, longChains: longChains diff --git a/app/pages/upload/application.pug b/app/pages/upload/application.pug index 7364c1e..a15ba20 100644 --- a/app/pages/upload/application.pug +++ b/app/pages/upload/application.pug @@ -24,4 +24,5 @@ nav.navbar.navbar-default label(for="example") Examples div: button(type="btn btn-default", id="example1") webpack 1 test cases div: button(type="btn btn-default", id="example2") webpack 5 test cases + div: button(type="btn btn-default", id="example3") hint test cases .modal-footer \ No newline at end of file diff --git a/app/pages/upload/example3.json b/app/pages/upload/example3.json new file mode 100644 index 0000000..b811f32 --- /dev/null +++ b/app/pages/upload/example3.json @@ -0,0 +1,992 @@ +{ + "version": "5.94.0", + "hash": "3f2a91c7d0be845516ad", + "time": 412, + "publicPath": "", + "outputPath": "/app/dist", + "assetsByChunkName": { + "main": [ + "main.js" + ], + "lazy": [ + "lazy.js" + ] + }, + "assets": [ + { + "name": "main.js", + "size": 14820, + "chunks": [ + 0 + ], + "chunkNames": [ + "main" + ], + "emitted": true + }, + { + "name": "lazy.js", + "size": 3140, + "chunks": [ + 1 + ], + "chunkNames": [ + "lazy" + ], + "emitted": true + } + ], + "chunks": [ + { + "id": 0, + "names": [ + "main" + ], + "size": 8140, + "files": [ + "main.js" + ], + "hash": "4b1d0f1b9a2c8d5e6f70", + "parents": [], + "rendered": true, + "initial": true, + "entry": true, + "origins": [ + { + "moduleIdentifier": null, + "module": "", + "moduleName": "", + "moduleId": null, + "loc": "main", + "name": "main", + "reasons": [] + } + ] + }, + { + "id": 1, + "names": [ + "lazy" + ], + "size": 4420, + "files": [ + "lazy.js" + ], + "hash": "0a7c3e5f2b6d4918c3ab", + "parents": [ + 0 + ], + "rendered": true, + "initial": false, + "entry": false, + "origins": [ + { + "moduleIdentifier": "/app/src/lazy-a.js", + "module": "./src/lazy-a.js", + "moduleName": "./src/lazy-a.js", + "moduleId": 8, + "loc": "4:8-32", + "name": "lazy", + "reasons": [ + "import()" + ] + } + ] + } + ], + "modules": [ + { + "id": 1, + "identifier": "/app/src/index.js", + "name": "./src/index.js", + "index": 0, + "index2": 18, + "size": 640, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": null, + "issuerId": null, + "issuerName": null, + "profile": { + "factory": 21, + "building": 38, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": null, + "module": null, + "moduleName": null, + "type": "entry", + "explanation": "", + "userRequest": "./src/index.js", + "loc": "main", + "moduleId": null + } + ], + "source": "import \"./a\";\nimport \"./lazy-a\";\nimport \"./shared\";\nrequire(\"./c\");\nrequire(\"./hub\");\nrequire(\"./self\");\nrequire(\"./noise-exports\");\nrequire(\"./noise-decorator\");\nrequire(\"./dangling\");\nconst { format } = require(\"./utils\");\nconst { parse } = require(\"./utils\");\nconst { stringify } = require(\"./utils\");\nconst { clone } = require(\"./utils\");\nconst { merge } = require(\"./utils\");\nconst { pick } = require(\"./utils\");" + }, + { + "id": 2, + "identifier": "/app/src/a.js", + "name": "./src/a.js", + "index": 1, + "index2": 17, + "size": 210, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 14, + "building": 26, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "harmony side effect evaluation", + "explanation": "", + "userRequest": "./a", + "loc": "1:0-13", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/b.js", + "module": "./src/b.js", + "moduleName": "./src/b.js", + "type": "harmony import specifier", + "explanation": "", + "userRequest": "./a", + "loc": "1:0-26", + "moduleId": 3 + } + ], + "source": "import { b } from \"./b\";\n\nexport const a = () => \"a\" + b();\n" + }, + { + "id": 3, + "identifier": "/app/src/b.js", + "name": "./src/b.js", + "index": 2, + "index2": 16, + "size": 180, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/a.js", + "issuerId": 2, + "issuerName": "./src/a.js", + "profile": { + "factory": 12, + "building": 22, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 1, + "reasons": [ + { + "moduleIdentifier": "/app/src/a.js", + "module": "./src/a.js", + "moduleName": "./src/a.js", + "type": "harmony side effect evaluation", + "explanation": "", + "userRequest": "./b", + "loc": "1:0-24", + "moduleId": 2 + } + ], + "source": "import { a } from \"./a\";\n\nexport const b = () => \"b\" + a();\n" + }, + { + "id": 4, + "identifier": "/app/src/c.js", + "name": "./src/c.js", + "index": 3, + "index2": 15, + "size": 260, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 16, + "building": 31, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./c", + "loc": "4:0-16", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/e.js", + "module": "./src/e.js", + "moduleName": "./src/e.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./c", + "loc": "1:10-24", + "moduleId": 6 + } + ], + "source": "const d = require(\"./d\");\n\nexports.c = () => d.first() + require(\"./d\").second();\n" + }, + { + "id": 5, + "identifier": "/app/src/d.js", + "name": "./src/d.js", + "index": 4, + "index2": 14, + "size": 240, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/c.js", + "issuerId": 4, + "issuerName": "./src/c.js", + "profile": { + "factory": 15, + "building": 29, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/c.js", + "module": "./src/c.js", + "moduleName": "./src/c.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./d", + "loc": "1:10-24", + "moduleId": 4 + }, + { + "moduleIdentifier": "/app/src/c.js", + "module": "./src/c.js", + "moduleName": "./src/c.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./d", + "loc": "3:31-45", + "moduleId": 4 + } + ], + "source": "const e = require(\"./e\");\n\nexports.first = () => e.value;\nexports.second = () => 2;\n" + }, + { + "id": 6, + "identifier": "/app/src/e.js", + "name": "./src/e.js", + "index": 5, + "index2": 13, + "size": 190, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/d.js", + "issuerId": 5, + "issuerName": "./src/d.js", + "profile": { + "factory": 13, + "building": 24, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/d.js", + "module": "./src/d.js", + "moduleName": "./src/d.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./e", + "loc": "1:10-24", + "moduleId": 5 + } + ], + "source": "const c = require(\"./c\");\n\nexports.value = c.c;\n" + }, + { + "id": 7, + "identifier": "/app/src/self.js", + "name": "./src/self.js", + "index": 6, + "index2": 12, + "size": 150, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 11, + "building": 18, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./self", + "loc": "6:0-19", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/self.js", + "module": "./src/self.js", + "moduleName": "./src/self.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./self", + "loc": "2:17-38", + "moduleId": 7 + } + ], + "source": "exports.late = function() {\n\treturn require(\"./self\").late;\n};\n" + }, + { + "id": 8, + "identifier": "/app/src/lazy-a.js", + "name": "./src/lazy-a.js", + "index": 7, + "index2": 11, + "size": 300, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 18, + "building": 34, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "harmony side effect evaluation", + "explanation": "", + "userRequest": "./lazy-a", + "loc": "2:0-19", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/lazy-b.js", + "module": "./src/lazy-b.js", + "moduleName": "./src/lazy-b.js", + "type": "harmony import specifier", + "explanation": "", + "userRequest": "./lazy-a", + "loc": "1:0-36", + "moduleId": 9 + } + ], + "source": "export const name = \"lazy-a\";\n\nexport function open() {\n\treturn import(\"./lazy-b\");\n}\n" + }, + { + "id": 9, + "identifier": "/app/src/lazy-b.js", + "name": "./src/lazy-b.js", + "index": 8, + "index2": 10, + "size": 220, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 1 + ], + "assets": [], + "issuer": "/app/src/lazy-a.js", + "issuerId": 8, + "issuerName": "./src/lazy-a.js", + "profile": { + "factory": 17, + "building": 27, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/lazy-a.js", + "module": "./src/lazy-a.js", + "moduleName": "./src/lazy-a.js", + "type": "import()", + "explanation": "", + "userRequest": "./lazy-b", + "loc": "4:8-32", + "moduleId": 8 + } + ], + "source": "import { name } from \"./lazy-a\";\nimport \"./shared\";\n\nexport default name + \"/lazy-b\";\n" + }, + { + "id": 10, + "identifier": "/app/src/hub.js", + "name": "./src/hub.js", + "index": 9, + "index2": 9, + "size": 340, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 19, + "building": 36, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./hub", + "loc": "5:0-18", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/alpha.js", + "module": "./src/alpha.js", + "moduleName": "./src/alpha.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./hub", + "loc": "1:12-28", + "moduleId": 11 + }, + { + "moduleIdentifier": "/app/src/gamma.js", + "module": "./src/gamma.js", + "moduleName": "./src/gamma.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./hub", + "loc": "1:12-28", + "moduleId": 13 + } + ], + "source": "exports.alpha = require(\"./alpha\");\nexports.beta = require(\"./beta\");\n" + }, + { + "id": 11, + "identifier": "/app/src/alpha.js", + "name": "./src/alpha.js", + "index": 10, + "index2": 8, + "size": 170, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/hub.js", + "issuerId": 10, + "issuerName": "./src/hub.js", + "profile": { + "factory": 12, + "building": 21, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/hub.js", + "module": "./src/hub.js", + "moduleName": "./src/hub.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./alpha", + "loc": "1:16-34", + "moduleId": 10 + } + ], + "source": "const hub = require(\"./hub\");\n\nexports.run = () => hub.beta;\n" + }, + { + "id": 12, + "identifier": "/app/src/beta.js", + "name": "./src/beta.js", + "index": 11, + "index2": 7, + "size": 175, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/hub.js", + "issuerId": 10, + "issuerName": "./src/hub.js", + "profile": { + "factory": 12, + "building": 23, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/hub.js", + "module": "./src/hub.js", + "moduleName": "./src/hub.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./beta", + "loc": "2:15-32", + "moduleId": 10 + } + ], + "source": "const gamma = require(\"./gamma\");\n\nexports.run = () => gamma.run();\n" + }, + { + "id": 13, + "identifier": "/app/src/gamma.js", + "name": "./src/gamma.js", + "index": 12, + "index2": 6, + "size": 165, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/beta.js", + "issuerId": 12, + "issuerName": "./src/beta.js", + "profile": { + "factory": 11, + "building": 20, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/beta.js", + "module": "./src/beta.js", + "moduleName": "./src/beta.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./gamma", + "loc": "1:14-34", + "moduleId": 12 + } + ], + "source": "const hub = require(\"./hub\");\n\nexports.run = () => hub.alpha;\n" + }, + { + "id": 14, + "identifier": "/app/src/utils.js", + "name": "./src/utils.js", + "index": 13, + "index2": 5, + "size": 480, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 15, + "building": 25, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./utils", + "loc": "10:22-40", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./utils", + "loc": "11:21-39", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./utils", + "loc": "12:25-43", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./utils", + "loc": "13:21-39", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./utils", + "loc": "14:21-39", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./utils", + "loc": "15:20-38", + "moduleId": 1 + } + ], + "source": "exports.format = () => {};\nexports.parse = () => {};\nexports.stringify = () => {};\nexports.clone = () => {};\nexports.merge = () => {};\nexports.pick = () => {};\n" + }, + { + "id": 15, + "identifier": "/app/src/shared.js", + "name": "./src/shared.js", + "index": 14, + "index2": 4, + "size": 4200, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0, + 1 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 24, + "building": 61, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "harmony side effect evaluation", + "explanation": "", + "userRequest": "./shared", + "loc": "3:0-18", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/lazy-b.js", + "module": "./src/lazy-b.js", + "moduleName": "./src/lazy-b.js", + "type": "harmony side effect evaluation", + "explanation": "", + "userRequest": "./shared", + "loc": "2:0-18", + "moduleId": 9 + } + ], + "source": "export const table = new Array(64).fill(\"shared\");\n" + }, + { + "id": 16, + "identifier": "/app/src/noise-exports.js", + "name": "./src/noise-exports.js", + "index": 15, + "index2": 3, + "size": 140, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 10, + "building": 17, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./noise-exports", + "loc": "7:0-29", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/noise-exports.js", + "module": "./src/noise-exports.js", + "moduleName": "./src/noise-exports.js", + "type": "cjs self exports reference", + "explanation": "", + "userRequest": null, + "loc": "1:0-14", + "moduleId": 16 + } + ], + "source": "exports.value = 42;\n" + }, + { + "id": 17, + "identifier": "/app/src/noise-decorator.js", + "name": "./src/noise-decorator.js", + "index": 16, + "index2": 2, + "size": 145, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 10, + "building": 16, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./noise-decorator", + "loc": "8:0-31", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/noise-decorator.js", + "module": "./src/noise-decorator.js", + "moduleName": "./src/noise-decorator.js", + "type": "module decorator", + "explanation": "", + "userRequest": null, + "loc": "1:0-33", + "moduleId": 17 + } + ], + "source": "module.exports = { value: 43 };\n" + }, + { + "id": 18, + "identifier": "/app/src/dangling.js", + "name": "./src/dangling.js", + "index": 17, + "index2": 1, + "size": 155, + "cacheable": true, + "built": true, + "optional": false, + "prefetched": false, + "chunks": [ + 0 + ], + "assets": [], + "issuer": "/app/src/index.js", + "issuerId": 1, + "issuerName": "./src/index.js", + "profile": { + "factory": 10, + "building": 19, + "dependencies": 0 + }, + "failed": false, + "errors": 0, + "warnings": 0, + "reasons": [ + { + "moduleIdentifier": "/app/src/index.js", + "module": "./src/index.js", + "moduleName": "./src/index.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./dangling", + "loc": "9:0-24", + "moduleId": 1 + }, + { + "moduleIdentifier": "/app/src/removed.js", + "module": "./src/removed.js", + "moduleName": "./src/removed.js", + "type": "cjs require", + "explanation": "", + "userRequest": "./dangling", + "loc": "3:12-33", + "moduleId": null + } + ], + "source": "exports.value = 44;\n" + } + ], + "errors": [], + "errorsCount": 0, + "warnings": [ + { + "moduleName": "./src/b.js", + "moduleIdentifier": "/app/src/b.js", + "moduleId": 3, + "loc": "1:0-24", + "message": "export 'a' (imported as 'a') was not found in './a' (module has no exports)\nThe import is part of a circular dependency, so './a' has not finished evaluating yet." + } + ], + "warningsCount": 1 +} diff --git a/app/pages/upload/page.js b/app/pages/upload/page.js index d28a05c..6c8f882 100644 --- a/app/pages/upload/page.js +++ b/app/pages/upload/page.js @@ -8,6 +8,7 @@ module.exports = function() { $("#file").change(loadFromFile); $("#example1").click(() => loadFromExample(1)); $("#example2").click(() => loadFromExample(2)); + $("#example3").click(() => loadFromExample(3)); function loadFromExample(n) { import(`./example${n}.json`).then(function(exampleModule) { diff --git a/package.json b/package.json index e503371..69041c2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "packageManager": "npm@11.19.0", "scripts": { "build": "rimraf dist && webpack --mode production --env longTermCaching --env googleAnalytics", - "dev": "webpack serve --mode development" + "dev": "webpack serve --mode development", + "test": "node --test" }, "dependencies": { "d3": "^7.0.0", diff --git a/test/findCircularDependencies.test.js b/test/findCircularDependencies.test.js new file mode 100644 index 0000000..022123e --- /dev/null +++ b/test/findCircularDependencies.test.js @@ -0,0 +1,169 @@ +// Checks the circular dependency detection against the sample stats in +// app/pages/upload/example3.json, which is also loadable in the UI as the +// "hint test cases" example. Run with `npm test`, which CI runs before the +// build. +var test = require("node:test"); +var assert = require("node:assert"); + +var app = require("../app/app"); +var findCircularDependencies = require("../app/findCircularDependencies"); +var stats = require("../app/pages/upload/example3.json"); + +// The stats are run through the same normalization the app uses, so the test +// covers the way reasons are turned into dependencies as well. Loading reports +// to google analytics, which falls back to console.log outside the browser. +var log = console.log; +console.log = function() {}; +app.load(stats); +console.log = log; + +var modules = app.stats.modules; + +// Every call passes its limits explicitly. The defaults are what the hints +// page settles for, not part of what is checked here, so changing them must +// not decide whether these assertions hold. +var NO_LIMIT = { maxCycles: 100, maxSteps: 100000 }; +var result = findCircularDependencies(modules, NO_LIMIT); + +function moduleByName(name) { + return modules.filter(function(module) { + return module.name === name; + })[0]; +} + +function cycleNames(cycle) { + return cycle.modules + .map(function(module) { + return module.name; + }) + .join(" -> "); +} + +test("finds every cycle of the sample stats, shortest first", function() { + assert.deepStrictEqual(result.cycles.map(cycleNames), [ + "./src/self.js", + "./src/a.js -> ./src/b.js", + "./src/lazy-a.js -> ./src/lazy-b.js", + "./src/hub.js -> ./src/alpha.js", + "./src/c.js -> ./src/d.js -> ./src/e.js", + "./src/hub.js -> ./src/beta.js -> ./src/gamma.js" + ]); + assert.strictEqual(result.truncated, false); +}); + +test("counts the modules and the groups they form", function() { + // a + b, c + d + e, self, lazy-a + lazy-b, hub + alpha + beta + gamma + assert.strictEqual(result.moduleCount, 12); + assert.strictEqual(result.componentCount, 5); +}); + +test("reports the dependency that closes each step of a cycle", function() { + result.cycles.forEach(function(cycle) { + var names = cycleNames(cycle); + cycle.modules.forEach(function(module, idx) { + var next = cycle.modules[(idx + 1) % cycle.modules.length]; + var dependency = cycle.dependencies[idx]; + assert.strictEqual(dependency.moduleUid, next.uid, names); + assert.ok(module.dependencies.indexOf(dependency) >= 0, names); + }); + }); +}); + +test("records type, request and location of each import", function() { + var cycle = result.cycles[1]; + assert.deepStrictEqual( + cycle.dependencies.map(function(dependency) { + return ( + dependency.type + " " + dependency.userRequest + " @" + dependency.loc + ); + }), + [ + "harmony side effect evaluation ./b @1:0-24", + "harmony import specifier ./a @1:0-26" + ] + ); +}); + +test("reports a cycle that is closed by a dynamic import", function() { + var cycle = result.cycles[2]; + assert.deepStrictEqual( + cycle.dependencies.map(function(dependency) { + return dependency.type; + }), + ["import()", "harmony import specifier"] + ); +}); + +test("uses the first of several references to a module", function() { + // c.js requires d.js twice, at 1:10-24 and at 3:31-45. + var cycle = result.cycles[4]; + assert.strictEqual(cycle.dependencies[0].loc, "1:10-24"); +}); + +test("ignores the reasons a module has for referring to itself", function() { + var noise = ["./src/noise-exports.js", "./src/noise-decorator.js"]; + noise.forEach(function(name) { + var module = moduleByName(name); + // The sample stats do contain such a reason for these modules, so this + // checks that the detection drops them, not that the fixture is quiet. + assert.ok( + module.dependencies.some(function(dependency) { + return dependency.moduleUid === module.uid; + }), + name + " should depend on itself in the loaded stats" + ); + assert.ok( + !result.cycles.some(function(cycle) { + return cycle.modules.indexOf(module) >= 0; + }), + name + " should not be reported as a cycle" + ); + }); +}); + +test("leaves out modules that are not part of a cycle", function() { + var reported = {}; + result.cycles.forEach(function(cycle) { + cycle.modules.forEach(function(module) { + reported[module.name] = true; + }); + }); + [ + "./src/index.js", + "./src/utils.js", + "./src/shared.js", + // dangling.js has a reason from a module that is not in the stats. + "./src/dangling.js" + ].forEach(function(name) { + assert.ok(!reported[name], name + " is not part of a cycle"); + }); +}); + +test("stops after the requested number of cycles", function() { + var limited = findCircularDependencies(modules, { + maxCycles: 2, + maxSteps: NO_LIMIT.maxSteps + }); + assert.strictEqual(limited.cycles.length, 2); + assert.strictEqual(limited.truncated, true); + // The counts describe the whole graph, not only the reported cycles. + assert.strictEqual(limited.moduleCount, result.moduleCount); + assert.strictEqual(limited.componentCount, result.componentCount); +}); + +test("stops when the search budget is used up", function() { + var limited = findCircularDependencies(modules, { + maxCycles: NO_LIMIT.maxCycles, + maxSteps: 1 + }); + assert.strictEqual(limited.truncated, true); +}); + +test("handles stats without any module", function() { + assert.deepStrictEqual(findCircularDependencies([], NO_LIMIT), { + cycles: [], + truncated: false, + moduleCount: 0, + componentCount: 0 + }); +});