-
Notifications
You must be signed in to change notification settings - Fork 14
Code PushUp integration guide for Nx monorepos
This is a guide for how to integrate Code PushUp CLI and ESLint plugin in an Nx monorepo, and how to automatically upload reports to portal's staging environment.
Warning
Only Nx 17 is supported. If your repo uses an older version, you'll need to update first - run npx nx migrate latest --interactive
(confirm latest versions of TypeScript and Angular), followed by npx nx migrate --run-migrations
(more info in Nx docs).
Code PushUp provides several recommended ESLint presets in the @code-pushup/eslint-config
NPM package. It's a quick way of setting up a strict ESLint configuration, which can report a large amount of potential problems or code style suggestions in any codebase. The intention isn't to fix all the issues right away, but rather to start tracking them with Code PushUp.
Tip
The configuration and setup will differ dependending on your tech stack. One example is given below, but refer to the official @code-pushup/eslint-config
docs for what other configs are available and how to set them up.
Note that you can either extend a config for your entire monorepo in the root .eslintrc.json
, or only extend it in a specific project's .eslintrc.json
instead. This may be useful when your monorepo is more diverse, e.g. only front-end projects would extend @code-pushup/eslint-config/angular
, but a back-end project would extend @code-pushup/eslint-config/node
instead, while @code-pushup/eslint-config/typescript
would be extended globally.
Example for Nx monorepo using Angular, Jest and Cypress
-
Install peer dependencies as required (for more info, see each config's setup docs):
npm i -D eslint-plugin-{cypress,deprecation,functional@latest,jest,import,promise,rxjs,sonarjs,unicorn@48} eslint-import-resolver-typescript
-
Install Code PushUp's ESLint config package:
npm i -D @code-pushup/eslint-config
-
In
.eslintrc.json
, extend configs:{ "root": true, "ignorePatterns": ["**/*"], "plugins": ["@nx"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, "allow": [], "depConstraints": [ { "sourceTag": "*", "onlyDependOnLibsWithTags": ["*"] } ] } ] } }, { "files": ["*.ts", "*.tsx", ".html"], // <-- .html needed for Angular config, should also be included in project.json's lintFilePatterns "extends": [ "plugin:@nx/typescript", // extend configs for TS files "@code-pushup/eslint-config/angular", "@code-pushup/eslint-config/jest", "@code-pushup/eslint-config/cypress" ], "settings": { // configure TS path aliases "import/resolver": { "typescript": { "project": "tsconfig.base.json" } } }, "rules": { // ... customize as needed ... "@angular-eslint/component-selector": [ "warn", { "type": "element", "style": "kebab-case", "prefix": ["cp"] // <-- replace with your own prefix } ], "@angular-eslint/directive-selector": [ "warn", { "type": "attribute", "style": "camelCase", "prefix": "cp" // <-- replace with your own prefix } ], "@angular-eslint/pipe-prefix": [ "warn", { "prefixes": ["cp"] // <-- replace with your own prefix } ], // if you wish to enforce control flow syntax: "@angular-eslint/template/prefer-control-flow": "warn" } }, { "files": ["*.js", "*.jsx"], "extends": ["plugin:@nx/javascript", "@code-pushup"], // add default config for JS files "rules": {} } ] }
-
Set
parserOptions.project
to correct tsconfig location in each Nx project's.eslintrc.json
(more info in Nx docs). E.g.:{ "extends": ["../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { "files": ["*.ts", "*.tsx"], "parserOptions": { "project": ["libs/utils/tsconfig.*?.json"] } } ] }
-
Test with
npx nx run-many -t lint
ornpx nx lint <project>
to see what errors and warnings are reported. You can customize or even disable rules using therules
section in.eslintrc.json
, if you need to tweak the configuration to better match your team's preferences, or even report an issue incode-pushup/eslint-config
repo.
At this point, you probably have a lot of problems being reported. If nx lint
is a required check in CI, some way to temporarily disable the failing rules is needed. While the CI should pass, we still want those problems to be reported to Code PushUp.
This can be achieved by renaming each project's .eslintrc.json
to code-pushup.eslintrc.json
, and creating a new .eslintrc.json
per project which extends ./code-pushup.eslintrc.json
with additional overrides which turn off failing rules. You can copy-paste and run the eslint-to-code-pushup.mjs
script (also pasted below) to automate this for you. The result should look like packages/core/.eslintrc.json
from the Code PushUp CLI repo, for example.
eslint-to-code-pushup.mjs
import {
createProjectGraphAsync,
readProjectsConfigurationFromProjectGraph,
} from '@nx/devkit';
import { ESLint } from 'eslint';
import minimatch from 'minimatch';
import fs from 'node:fs/promises';
import path from 'node:path';
// replace these patterns as needed
const TEST_FILE_PATTERNS = [
'*.spec.ts',
'*.test.ts',
'**/test/**/*',
'**/mock/**/*',
'**/mocks/**/*',
'*.cy.ts',
'*.stories.ts',
];
const graph = await createProjectGraphAsync({ exitOnError: true });
const projects = Object.values(
readProjectsConfigurationFromProjectGraph(graph).projects,
)
.filter(project => 'lint' in (project.targets ?? {}))
.sort((a, b) => a.root.localeCompare(b.root));
for (let i = 0; i < projects.length; i++) {
const project = projects[i];
/** @type {import('@nx/eslint/src/executors/lint/schema').Schema} */
const options = project.targets.lint.options;
const eslintrc = options.eslintConfig ?? `${project.root}/.eslintrc.json`;
const patterns = options.lintFilePatterns ?? project.root;
console.info(
`Processing Nx ${project.projectType ?? 'project'} "${project.name}" (${
i + 1
}/${projects.length}) ...`,
);
const eslint = new ESLint({
overrideConfigFile: eslintrc,
useEslintrc: false,
errorOnUnmatchedPattern: false,
resolvePluginsRelativeTo: options.resolvePluginsRelativeTo ?? undefined,
ignorePath: options.ignorePath ?? undefined,
rulePaths: options.rulesdir ?? [],
});
const results = await eslint.lintFiles(patterns);
/** @type {Set<string>} */
const failingRules = new Set();
/** @type {Set<string>} */
const failingRulesTestsOnly = new Set();
/** @type {Map<string, number>} */
const errorCounts = new Map();
/** @type {Map<string, number>} */
const warningCounts = new Map();
for (const result of results) {
const isTestFile = TEST_FILE_PATTERNS.some(pattern =>
minimatch(result.filePath, pattern),
);
for (const { ruleId, severity } of result.messages) {
if (isTestFile) {
if (!failingRules.has(ruleId)) {
failingRulesTestsOnly.add(ruleId);
}
} else {
failingRules.add(ruleId);
failingRulesTestsOnly.delete(ruleId);
}
if (severity === 1) {
warningCounts.set(ruleId, (warningCounts.get(ruleId) ?? 0) + 1);
} else {
errorCounts.set(ruleId, (errorCounts.get(ruleId) ?? 0) + 1);
}
}
}
/** @param {string} ruleId */
const formatCounts = ruleId =>
[
{ kind: 'error', count: errorCounts.get(ruleId) },
{ kind: 'warning', count: warningCounts.get(ruleId) },
]
.filter(({ count }) => count > 0)
.map(({ kind, count }) =>
count === 1 ? `1 ${kind}` : `${count} ${kind}s`,
)
.join(', ');
if (failingRules.size > 0) {
console.info(`• ${failingRules.size} rules need to be disabled`);
failingRules.forEach(ruleId => {
console.info(` - ${ruleId} (${formatCounts(ruleId)})`);
});
}
if (failingRulesTestsOnly.size > 0) {
console.info(
`• ${failingRulesTestsOnly.size} rules need to be disabled only for test files`,
);
failingRulesTestsOnly.forEach(ruleId => {
console.info(` - ${ruleId} (${formatCounts(ruleId)})`);
});
}
if (failingRules.size === 0 && failingRulesTestsOnly.size === 0) {
console.info('• no rules need to be disabled, nothing to do here\n');
continue;
}
const cpEslintrc =
'code-pushup.' + path.basename(eslintrc).replace(/^\./, '');
/** @param {Set<string>} rules */
const formatRules = (rules, indentLevel = 2) =>
Array.from(rules.values())
.sort((a, b) => {
if (a.includes('/') !== b.includes('/')) {
return a.includes('/') ? 1 : -1;
}
return a.localeCompare(b);
})
.map(
(ruleId, i, arr) =>
' '.repeat(indentLevel) +
`"${ruleId}": "off"${
i === arr.length - 1 ? '' : ','
} // ${formatCounts(ruleId)}`,
)
.join('\n')
.replace(/,$/, '');
/** @type {import('eslint').Linter.Config} */
const config = `{
"extends": ["./${cpEslintrc}"],
// temporarily disable failing rules so \`nx lint\` passes
// number of errors/warnings per rule recorded at ${new Date().toString()}
"rules": {
${formatRules(failingRules)}
}
${
!failingRulesTestsOnly.size
? ''
: `,
"overrides": [
{
"files": ${JSON.stringify(TEST_FILE_PATTERNS)},
"rules": {
${formatRules(failingRulesTestsOnly, 4)}
}
}
]`
}
}`;
const content = /\.c?[jt]s$/.test(eslintrc)
? `module.exports = ${config}`
: config;
const cpEslintrcPath = path.join(project.root, cpEslintrc);
await fs.copyFile(eslintrc, cpEslintrcPath);
console.info(`• copied ${eslintrc} to ${cpEslintrcPath}`);
await fs.writeFile(eslintrc, content);
console.info(
`• replaced ${eslintrc} to extend ${cpEslintrc} and disable failing rules\n`,
);
}
process.exit(0);
Verify that nx lint
now passes for all your projects.
Now that we have our ESLint configs ready, we can install @code-pushup/cli
and @code-pushup/eslint-plugin
and configure them to collect Code PushUp reports.
-
Install NPM packages:
npm i -D @code-pushup/cli @code-pushup/eslint-plugin
-
Create a
code-pushup.config.ts
file:import eslintPlugin, { eslintConfigFromNxProjects, } from '@code-pushup/eslint-plugin'; import type { CoreConfig } from '@code-pushup/models'; const config: CoreConfig = { plugins: [await eslintPlugin(await eslintConfigFromNxProjects())], categories: [ { slug: 'bug-prevention', title: 'Bug prevention', refs: [ { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, ], }, { slug: 'code-style', title: 'Code style', refs: [ { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }, ], }, ], }; export default config;
-
Add
.code-pushup
directory to your.gitignore
file. -
Collect a first report (be patient, it may take a while):
npx code-pushup collect
Report summary will be printed to terminal, and two files should be created:
-
.code-pushup/report.json
(for upload), -
.code-pushup/report.md
(full human-readable report).
-
Alternatives to linting all Nx projects at once
The configuration above will run ESLint on every project in the monorepo in one go. If you prefer to be more granular, then you have two other options:-
Use
eslintConfigFromNxProject
and specify the name of the project you wish to target.import eslintPlugin, { eslintConfigFromNxProject, } from '@code-pushup/eslint-plugin'; const config: CoreConfig = { // ... plugins: [await eslintPlugin(await eslintConfigFromNxProject('website'))], };
This will lint only this project and projects it depends on.
If you wish to target multiple projects as entry points (e.g. 2 different applications), create a
code-pushup.config.ts
for each of these projects. -
Create a
code-pushup.config.ts
for each project you want to lint, e.g.:const config: CoreConfig = { // ... persist: { // ... outputDir: 'dist/apps/website', }, plugins: [ await eslintPlugin({ // path to project's .eslintrc.json eslintrc: 'apps/website/.eslintrc.json', // same as lintFilePatterns in project.json patterns: ['apps/website/**/*.ts', 'apps/website/**/*.html'], }), ], };
This will produce individual reports for each project, each Nx project will be completely separated in the portal.
If you decide to use multiple code-pushup.config.ts
files, then you can still share common configuration by importing a root code-pushup.preset.ts
(similar to Jest). And you can create a custom code-pushup
target using nx:run-commands
executor in order to use with nx affected
and nx run-many
.
In order to upload the report to the portal, you'll need to have access to an organization in the staging environment.
- Sign in with your work email address to Code PushUp portal in staging. You should then see organizations where your email (or domain) has been whitelisted, along with their projects.
- Select any project in your organization and go to Settings (menu in top-right corner):
- Click Create API key, give it a name, expiration and choose read/write permissions.
- Copy the generated API key to your clipboard and store it securely for later use.
Note
If your repository already has some project in the Code PushUp portal staging environment, then you may skip this section.
If your repository is on GitHub, then the Code PushUp (staging) GitHub App must be installed there, so that the portal has permissions to query for branches and commits.
If the project doesn't yet exist in the staging database, it needs to be added to the organization and linked to the correct repository. This can be done in the portal repository with npm run add-organization
and subsequently npm run add-project
(requires MONGODB_URI
environment variable with connection string for staging DB).
In code-pushup.config.ts
, add configuration for the upload
command:
// optional, if you want to use .env file:
// import 'dotenv/config';
const config: CoreConfig = {
// ... plugins, categories, etc. ...
upload: {
// portal API for staging environment
server: 'https://api.staging.code-pushup.dev/graphql',
// API key you created earlier, but use environment variable to keep it secret
apiKey: process.env.CP_API_KEY!,
// replace with the slug of your organization
organization: 'code-pushup',
// replace with the slug of your project
project: 'cli',
},
};
Then set the CP_API_KEY
environment variable to the value you created earlier and run the upload
command (will look for .code-pushup/report.json
file):
CP_API_KEY=... npx code-pushup upload
Warning
The latest commit has to be pushed to remote, otherwise the upload will fail because it can't verify the commit exists.
Add the API key you created earlier to your CI's secrets manager (e.g. as a GitHub Actions secret).
Run npx code-pushup autorun
as a shell script in a CI job that's triggered on push. For GitHub Actions, it should look something like this:
name: Code PushUp
on: push
jobs:
code_pushup:
runs-on: ubuntu-latest
name: Code PushUp
steps:
# prerequisites
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
# Code PushUp command
- name: Collect and upload Code PushUp report
run: npx code-pushup autorun
env:
# provide secret as environment variable
CP_API_KEY: ${{ secrets.CP_API_KEY }}
# add other options as needed
NODE_OPTIONS: --max-old-space-size=8192
# optional extra step if you want to preserve report.json, report.md
- name: Save report files as workflow artifact
uses: actions/upload-artifact@v3
with:
name: code-pushup-report
path: .code-pushup/