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 setup docs):

    npm i -D eslint-plugin-{deprecation,functional@latest,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"],
          "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.

Clone this wiki locally