diff --git a/.gitignore b/.gitignore index 9451c57cbc4..a0c3dbfcd7e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /gopath /Godeps/_workspace/src/github.com/openshift/console /frontend/.cache-loader +/frontend/.webpack-cycles /frontend/__coverage__ /frontend/__chrome_browser__ /frontend/**/node_modules diff --git a/frontend/package.json b/frontend/package.json index 033cd41f003..4c58bdf58d1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dev": "yarn clean && NODE_OPTIONS=--max-old-space-size=4096 ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/webpack-dev-server", "dev-once": "yarn clean && NODE_OPTIONS=--max-old-space-size=4096 ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/webpack --mode=development", "build": "yarn clean && NODE_ENV=production NODE_OPTIONS=--max-old-space-size=4096 ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/webpack --mode=production", + "check-cycles": "CHECK_CYCLES=true yarn dev-once", "coverage": "jest --coverage .", "eslint": "eslint --ext .js,.jsx,.ts,.tsx,.json --color", "lint": "NODE_OPTIONS=--max-old-space-size=4096 yarn eslint .", @@ -76,13 +77,13 @@ "dependencies": { "@fortawesome/fontawesome-free": "^5.9.0", "@patternfly/patternfly": "2.65.3", + "@patternfly/react-catalog-view-extension": "1.4.11", "@patternfly/react-charts": "5.2.2", "@patternfly/react-core": "3.140.11", "@patternfly/react-table": "2.24.41", "@patternfly/react-tokens": "2.7.10", "@patternfly/react-topology": "2.11.27", "@patternfly/react-virtualized-extension": "1.3.40", - "@patternfly/react-catalog-view-extension": "1.4.11", "abort-controller": "3.0.0", "classnames": "2.x", "core-js": "2.x", @@ -165,7 +166,7 @@ "cache-loader": "1.x", "chalk": "2.3.x", "chromedriver": "77.x", - "circular-dependency-plugin": "5.0.2", + "circular-dependency-plugin": "5.x", "css-loader": "0.28.x", "enzyme": "3.10.x", "enzyme-adapter-react-16": "1.15.2", @@ -182,6 +183,7 @@ "jest": "21.x", "jest-cli": "21.x", "mini-css-extract-plugin": "0.4.x", + "moment": "2.22.x", "monaco-editor-core": "0.14.0", "monaco-editor-webpack-plugin": "^1.7.0", "node-sass": "4.13.x", diff --git a/frontend/webpack.circular-deps.ts b/frontend/webpack.circular-deps.ts new file mode 100644 index 00000000000..74c5e6dd593 --- /dev/null +++ b/frontend/webpack.circular-deps.ts @@ -0,0 +1,83 @@ +/* eslint-env node */ +/* eslint-disable no-console */ + +import * as webpack from 'webpack'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as moment from 'moment'; +import * as CircularDependencyPlugin from 'circular-dependency-plugin'; + +type PresetOptions = { + exclude: RegExp; + reportFile: string; +}; + +type DetectedCycle = { + // webpack module record that caused the cycle + causedBy: string; + // relative module paths that make up the cycle + modulePaths: string[]; +}; + +export class CircularDependencyPreset { + private readonly HandleCyclesPluginName = 'HandleCyclesPlugin'; + + constructor(private readonly options: PresetOptions) {} + + private getCycleReport(cycles: DetectedCycle[], compilation: webpack.compilation.Compilation) { + const hash = compilation.getStats().hash; + const builtAt = moment(compilation.getStats().endTime).format('MM/DD/YYYY HH:mm:ss'); + + const countByDir = cycles + .map((c) => c.modulePaths[0].replace(/\/.*$/, '')) + .reduce((acc, dir) => { + acc[dir] = (acc[dir] ?? 0) + 1; + return acc; + }, {} as { [key: string]: number }); + + const header = + `# webpack compilation ${hash} built at ${builtAt}\n` + + '# this file is auto-generated on every webpack development build\n'; + + const stats = + `# ${cycles.length} total cycles: ${Object.keys(countByDir) + .map((d) => `${d} (${countByDir[d]})`) + .join(', ')}\n` + + `# ${ + cycles.filter((c) => c.modulePaths.length === 3).length + } minimal-length cycles (A -> B -> A)\n`; + + const entries = cycles.map((c) => `${c.causedBy}\n${c.modulePaths.join('\n-> ')}\n`).join('\n'); + + return [header, stats, entries].join('\n'); + } + + apply(plugins: webpack.Plugin[]) { + const cycles: DetectedCycle[] = []; + + plugins.push( + new CircularDependencyPlugin({ + exclude: this.options.exclude, + onDetected: ({ module: { resource }, paths: modulePaths }) => { + cycles.push({ causedBy: resource, modulePaths }); + }, + }), + { + // Ad-hoc plugin to handle detected module cycle information + apply: (compiler) => { + compiler.hooks.emit.tap(this.HandleCyclesPluginName, (compilation) => { + if (cycles.length === 0) { + return; + } + + const reportPath = path.resolve(__dirname, this.options.reportFile); + fs.writeFileSync(reportPath, this.getCycleReport(cycles, compilation)); + + console.log(`detected ${cycles.length} cycles`); + console.log(`module cycle report written to ${reportPath}`); + }); + }, + }, + ); + } +} diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index 6c5574592ee..b00dca313ed 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -8,6 +8,7 @@ import * as VirtualModulesPlugin from 'webpack-virtual-modules'; import { resolvePluginPackages, getActivePluginsModule } from '@console/plugin-sdk/src/codegen'; import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server'; +import { CircularDependencyPreset } from './webpack.circular-deps'; interface Configuration extends webpack.Configuration { devServer?: WebpackDevServerConfiguration; @@ -15,8 +16,9 @@ interface Configuration extends webpack.Configuration { const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); -const NODE_ENV = process.env.NODE_ENV; -const HOT_RELOAD = process.env.HOT_RELOAD; +const NODE_ENV = process.env.NODE_ENV || 'development'; +const HOT_RELOAD = process.env.HOT_RELOAD || 'false'; +const CHECK_CYCLES = process.env.CHECK_CYCLES || 'false'; /* Helpers */ const extractCSS = new MiniCssExtractPlugin({ filename: 'app-bundle.[contenthash].css' }); @@ -163,6 +165,13 @@ const config: Configuration = { stats: 'minimal', }; +if (CHECK_CYCLES === 'true') { + new CircularDependencyPreset({ + exclude: /node_modules|public\/dist/, + reportFile: '.webpack-cycles', + }).apply(config.plugins); +} + /* Production settings */ if (NODE_ENV === 'production') { config.output.filename = '[name]-bundle-[hash].min.js'; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index aeed63ef62c..105787092d1 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4783,10 +4783,10 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" -circular-dependency-plugin@5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.0.2.tgz#da168c0b37e7b43563fb9f912c1c007c213389ef" - integrity sha512-oC7/DVAyfcY3UWKm0sN/oVoDedQDQiw/vIiAnuTWTpE5s0zWf7l3WY417Xw/Fbi/QbAjctAkxgMiS9P0s3zkmA== +circular-dependency-plugin@5.x: + version "5.2.0" + resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.2.0.tgz#e09dbc2dd3e2928442403e2d45b41cea06bc0a93" + integrity sha512-7p4Kn/gffhQaavNfyDFg7LS5S/UT1JAjyGd4UqR2+jzoYF02eDkj0Ec3+48TsIa4zghjLY87nQHIh/ecK9qLdw== clap@^1.0.9: version "1.2.3" @@ -11259,7 +11259,7 @@ moment-timezone@^0.4.0, moment-timezone@^0.4.1: dependencies: moment ">= 2.6.0" -"moment@>= 2.6.0", moment@^2.10, moment@^2.19.1: +moment@2.22.x, "moment@>= 2.6.0", moment@^2.10, moment@^2.19.1: version "2.22.2" resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"