https://github.com/grafana/grafana
Raw File
Tip revision: 669250678b6b2aa2a6c3d355b65123e29e13ce66 authored by marco.schaefer on 03 May 2024, 13:37:14 UTC
add tabindex=0 if link is disabled to make it focusable.
Tip revision: 6692506
.betterer.ts
import { BettererFileTest } from '@betterer/betterer';
import { promises as fs } from 'fs';
import { ESLint, Linter } from 'eslint';
import path from 'path';
import { glob } from 'glob';

// Why are we ignoring these?
// They're all deprecated/being removed so doesn't make sense to fix types
const eslintPathsToIgnore = [
  'public/app/angular', // will be removed in Grafana 11
  'public/app/plugins/panel/graph', // will be removed alongside angular
  'public/app/plugins/panel/table-old', // will be removed alongside angular
];

// Avoid using functions that report the position of the issues, as this causes a lot of merge conflicts
export default {
  'better eslint': () =>
    countEslintErrors()
      .include('**/*.{ts,tsx}')
      .exclude(new RegExp(eslintPathsToIgnore.join('|'))),
  'no undocumented stories': () => countUndocumentedStories().include('**/!(*.internal).story.tsx'),
  'no gf-form usage': () =>
    regexp(
      /gf-form/gm,
      'gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.'
    ).include('**/*.{ts,tsx,html}'),
};

function countUndocumentedStories() {
  return new BettererFileTest(async (filePaths, fileTestResult) => {
    await Promise.all(
      filePaths.map(async (filePath) => {
        // look for .mdx import in the story file
        const mdxImportRegex = new RegExp("^import.*\\.mdx';$", 'gm');
        // Looks for the "autodocs" string in the file
        const autodocsStringRegex = /autodocs/;

        const fileText = await fs.readFile(filePath, 'utf8');

        const hasMdxImport = mdxImportRegex.test(fileText);
        const hasAutodocsString = autodocsStringRegex.test(fileText);
        // If both .mdx import and autodocs string are missing, add an issue
        if (!hasMdxImport && !hasAutodocsString) {
          // In this case the file contents don't matter:
          const file = fileTestResult.addFile(filePath, '');
          // Add the issue to the first character of the file:
          file.addIssue(0, 0, 'No undocumented stories are allowed, please add an .mdx file with some documentation');
        }
      })
    );
  });
}

/**
 *  Generic regexp pattern matcher, similar to @betterer/regexp.
 *  The only difference is that the positions of the errors are not reported, as this may cause a lot of merge conflicts.
 */
function regexp(pattern: RegExp, issueMessage: string) {
  return new BettererFileTest(async (filePaths, fileTestResult) => {
    await Promise.all(
      filePaths.map(async (filePath) => {
        const fileText = await fs.readFile(filePath, 'utf8');
        const matches = fileText.match(pattern);
        if (matches) {
          // File contents doesn't matter, since we're not reporting the position
          const file = fileTestResult.addFile(filePath, '');
          matches.forEach(() => {
            file.addIssue(0, 0, issueMessage);
          });
        }
      })
    );
  });
}

function countEslintErrors() {
  return new BettererFileTest(async (filePaths, fileTestResult, resolver) => {
    const { baseDirectory } = resolver;
    const cli = new ESLint({ cwd: baseDirectory });

    const eslintConfigFiles = await glob('**/.eslintrc');
    const eslintConfigMainPaths = eslintConfigFiles.map((file) => path.resolve(path.dirname(file)));

    const baseRules: Partial<Linter.RulesRecord> = {
      '@emotion/syntax-preference': [2, 'object'],
      '@typescript-eslint/no-explicit-any': 'error',
      '@grafana/no-aria-label-selectors': 'error',
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['@grafana/ui*', '*/Layout/*'],
              importNames: ['Layout', 'HorizontalGroup', 'VerticalGroup'],
              message: 'Use Stack component instead.',
            },
          ],
        },
      ],
    };

    const nonTestFilesRules: Partial<Linter.RulesRecord> = {
      ...baseRules,
      '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }],
    };

    const grafanaRules: Partial<Linter.RulesRecord> = {
      ...nonTestFilesRules,
      'no-barrel-files/no-barrel-files': 'error',
    };

    const testFilesAndGrafanaRules: Partial<Linter.RulesRecord> = {
      ...baseRules,
      'no-barrel-files/no-barrel-files': 'error',
    };

    // group files by eslint config file
    // this will create two file groups for each eslint config file
    // one for test files and one for non-test files
    const fileGroups: Record<string, string[]> = {};

    for (const filePath of filePaths) {
      let configPath = eslintConfigMainPaths.find((configPath) => filePath.startsWith(configPath)) ?? '';
      const isTestFile =
        filePath.endsWith('.test.tsx') ||
        filePath.endsWith('.test.ts') ||
        filePath.includes('__mocks__') ||
        filePath.includes('public/test/');
      const isGrafanaFile = filePath.includes('public/app/');

      if (isGrafanaFile && isTestFile) {
        configPath += '-test-grafana';
      } else if (isGrafanaFile) {
        configPath += '-grafana';
      } else if (isTestFile) {
        configPath += '-test';
      }

      if (!fileGroups[configPath]) {
        fileGroups[configPath] = [];
      }
      fileGroups[configPath].push(filePath);
    }

    for (const configPath of Object.keys(fileGroups)) {
      let rules;
      if (configPath.endsWith('-test-grafana')) {
        rules = testFilesAndGrafanaRules;
      } else if (configPath.endsWith('-test')) {
        rules = baseRules;
      } else if (configPath.endsWith('-grafana')) {
        rules = grafanaRules;
      } else {
        rules = nonTestFilesRules;
      }
      // this is by far the slowest part of this code. It takes eslint about 2 seconds just to find the config
      const linterOptions = (await cli.calculateConfigForFile(fileGroups[configPath][0])) as Linter.Config;
      const runner = new ESLint({
        baseConfig: {
          ...linterOptions,
          rules: rules,
        },
        useEslintrc: false,
        cwd: baseDirectory,
      });
      const lintResults = await runner.lintFiles(fileGroups[configPath]);
      lintResults
        .filter((lintResult) => lintResult.source)
        .forEach((lintResult) => {
          const { messages } = lintResult;
          const filePath = lintResult.filePath;
          const file = fileTestResult.addFile(filePath, '');
          messages.forEach((message, index) => {
            file.addIssue(0, 0, message.message, `${index}`);
          });
        });
    }
  });
}
back to top