Skip to content

Code PushUp integration guide for Nx monorepos

Matěj Chalk edited this page Dec 5, 2023 · 26 revisions

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).

ESLint config

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 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
  1. 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,no-secrets,promise,rxjs,sonarjs,unicorn@48}
  2. Install Code PushUp's ESLint config package:

    npm i -D @code-pushup/eslint-config
  3. 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
              }
            ]
          }
        },
        {
          "files": ["*.js", "*.jsx"],
          "extends": ["plugin:@nx/javascript", "@code-pushup"], // add default config for JS files
          "rules": {}
        }
      ]
    }
  4. 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"]
          }
        }
      ]
    }
  5. Test with npx nx run-many -t lint or npx nx lint <project> to see what errors and warnings are reported. You can customize or even disable rules using the rules section in .eslintrc.json, if you need to tweak the configuration to better match your team's preferences, or even report an issue in code-pushup/eslint-config repo.

Nx lint in CI

At this point, you probably have a lot of problems being reported. If nx lint is 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 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;

  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.

Code PushUp CLI and ESLint plugin

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.

  1. Install NPM packages:

    npm i -D @code-pushup/{cli,eslint-plugin}
  2. 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 = {
      persist: {
        outputDir: '.code-pushup',
        filename: 'report',
        format: ['json', 'md'],
      },
      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;
  3. Add .code-pushup directory to .gitignore file.

  4. Collect a first report (be patient, it may take a while):

    npx code-pushup collect --config code-pushup.config.ts

    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).
Expand to see alternatives to running on all Nx projects at once The configuration above will run ESLint on every project in the monorepo at once. If you prefer to be more granular, then you have two other options:
  1. 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.
  2. Create a code-pushup.config.ts for each project you want to lint and use:

    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, but they won't be combined in portal at all.

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 if you can create a custom code-pushup target using nx:run-commands executor in order to use with nx affected and nx run-many.

Clone this wiki locally