UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

326 lines (294 loc) 10.4 kB
const term = require('../util/term') const io = require('./io') const cds = require('../../lib/cds') const { path, isfile, readdir } = cds.utils const { exit } = require('node:process') const { pathToFileURL } = require('node:url') const LOG = cds.debug('lint') const LOG_CONFIG = cds.debug('lint:config') const GENERAL_ERROR = 1 const FATAL_ERROR = 2 /** * @param {{errorCount: number, fatalErrorCount: number}[]} results */ const countErrors = results => results.reduce((acc, result) => { acc.errorCount += result.errorCount acc.fatalErrorCount += result.fatalErrorCount return acc }, { errorCount: 0, fatalErrorCount: 0 }) /** * Safely require a module, return empty object if module not found * @param {string} module - module to require * @param {boolean} esm - whether to use dynamic import to accomodate ESM * @returns {Promise<ReturnType<NodeRequire> | {}>} module or empty object */ async function tryRequire(module, esm = false) { try { const modulePath = require.resolve(module, { paths: [process.cwd()] }) const result = esm // pathToFileURL is used to convert the path to a file URL on Windows ? (await import(pathToFileURL(modulePath))).default : require(modulePath) return result } catch (e) { if (e.code === 'MODULE_NOT_FOUND' && e.message.includes(module)) return {} // target module is either .mjs (etc) or user project is ESM if (['ERR_REQUIRE_ASYNC_MODULE', 'ERR_REQUIRE_ESM'].includes(e.code)) return tryRequire(module, true) throw e } } /** * Ascends through directory tree to find the first config file. * @param {string} currentDir - current directory to start search * @returns {string} - path to config file */ async function findConfigPath (currentDir = '.') { // possible config file paths const configFiles = [ 'eslint.config.js', 'eslint.config.cjs', 'eslint.config.mjs', // the following 3 formats require additional setup by the user as of today, // see https://eslint.org/docs/latest/use/configure/configuration-files#typescript-configuration-files 'eslint.config.ts', 'eslint.config.mts', 'eslint.config.cts', ] let configDir = path.resolve(currentDir) while (configDir && configDir !== path.dirname(configDir)) { for (const configFile of configFiles) { try { const configPath = path.join(configDir, configFile) const config = await tryRequire(configPath) if ((configFile !== 'package.json' || config?.eslintConfig) && isfile(configPath)) { return configPath } } catch { // attempting to load config files may fail for various reasons we can not easily verify by error code, for example // "Cannot use import statement outside a module" in a TS context, or attempting to read .mts files without properly setting them up. // We just ignore these errors and treat them as "no valid config found so far, continue searching", lest the exception flys up. } } configDir = path.dirname(configDir) } return '' } class Linter { /** @type {string} */ eslintCmd = 'npx eslint' /** @type {string | string[]} */ eslintCmdFileExpr = '' /** @type {string} */ help = '' /** @type {string} */ debug = '' /** @type {string[]} */ flags = [] /** @type {import('fs').PathLike} */ #configPath async #getConfigPath () { return this.#configPath ??= await findConfigPath(process.cwd()) } /** @type {object} */ #configContents async #getConfigContents () { if (!this.#configContents) { if (!(await this.#getConfigPath())) { this.#configContents = io.sanitizeEslintConfig({}, LOG) } else { const cc = await io.readEslintConfig(await this.#getConfigPath()) this.#configContents = cc const cdsConfig = Array.isArray(cc) ? cc?.find(c => c.files?.some(f => f.includes('*.cds'))) ?? {} : cc this.#configContents = cdsConfig.files ? { ...cdsConfig.plugins.configs?.recommended, languageOptions: cdsConfig.languageOptions, files: cdsConfig.files, rules: cdsConfig.rules, } : {} } } return this.#configContents } /** @type {string[]} */ fileExtensions = [] /** @type {string} */ #pluginPath async #getPluginPath () { if (this.#pluginPath === undefined) { try { this.#pluginPath = require.resolve('@sap/eslint-plugin-cds', { paths: [path.dirname(await this.#getConfigPath())], }) } catch { // CLI will report (plugin not installed) this.#pluginPath = null } } return this.#pluginPath } /** @type {NodeJS.Module} */ #pluginApi async #getPluginApi () { return this.#pluginApi ??= require((await this.#getPluginPath()).replace('index.js', 'api')) } /** @type {object} */ ruleOpts = {} /** @type {array} */ customRulesOpts = [] /** @type {array} */ pluginRules = [] /** * Initializes `cds lint` call and generates the required content * object for `cds` executable * @returns {{ * help: string, * options: string[], * flags: unknown[], * shortcuts: unknown[], * }} object containing help, options, flags and shortcuts */ init() { const { help, options, shortcuts, flags } = require('./eslintHelp.json') this.help = help this.flags = flags return { help, flags, options, shortcuts } } /** * Runner for 'cds lint' which is a wrapper for eslint cmd calls * that detects and adds the required cmd line arguments * @param {string[]} args files/globs for eslint to lint * @param {{[key: string]: unknown}} options options/flag passed by user */ async lint(args, options) { // user should install eslint locally const { loadESLint } = await tryRequire('eslint') if (!loadESLint) throw 'ESLint is not installed. Please install ESLint in your project using \'npm i -D eslint\'.' const DefaultESLint = await loadESLint() if (options?.version) { return this.#printVersion(DefaultESLint.version) } this.eslintCmdOpts = options this.eslintCmdFileExpr = args.length ? args : ['.'] if (this.eslintCmdOpts.help) return this.#printHelp() if (await this.#getPluginPath()) { await this.#overwriteRuleSeverities() // Limit to CDS file extensions await this.#addExtensions() } // Run ESLint with collected options try { await this.#runEslint(DefaultESLint) } catch (err) { term.error(err) } } /** * Prints help message */ #printHelp() { console.log(this.help.replace(/ \*([^*]+)\*/g, ` ${term.codes.bold}$1${term.codes.reset}`)) } #printVersion(version) { console.log(`Using ESLint version ${term.codes.bold}${version}${term.codes.reset}`) } async #addExtensions() { // Add CDS file extensions to lint this.fileExtensions = (await this.#getPluginApi()).getFileExtensions() .map(ext => path.extname(ext)) // Only lint file extensions prescribed by plugin this.ignorePatterns = this.fileExtensions .map(ext => `!${ext}`) } async #runEslint(DefaultESLint) { try { const eslintOpts = { cwd: process.cwd(), overrideConfig: await this.#getConfigContents(), } const cdsPlugin = require(await this.#getPluginPath()) // Exclude our own apis from plugin to avoid wrapper errors //delete cdsPlugin.configs.recommended?.plugins?.['@sap/cds'] eslintOpts.overrideConfig = [cdsPlugin.configs.recommended] if (this.customRulesOpts?.length) { eslintOpts.rulePaths = [this.customRulesOpts] } LOG_CONFIG?.(eslintOpts) if (LOG) { const lintString = ['npx eslint'] if (this.fileExtensions) { lintString.push(`--ext "${this.fileExtensions.join(',')}"`) } for (const [name, rule] of Object.entries(this.ruleOpts)) { lintString.push(`--rule ${name}:${rule}`) } if (this.customRulesOpts?.length) { lintString.push(`--rulesdir "${this.customRulesOpts}"`) } LOG(lintString.join(' ')) } if (process.env.isTest) // only log config and hypothetical lintString, do not actually run return /** @type {import('eslint').ESLint} */ const eslint = new DefaultESLint(eslintOpts) const formatter = await eslint.loadFormatter('stylish') // environment rules const results = (await eslint.lintText('')) .filter(result => result.messages.length) .map((result) => { result.filePath = path.resolve(process.cwd()) return result }) // lint files in current dir const files = await readdir(process.cwd()) if (files.some(file => isfile(file))) { results.push(...await eslint.lintFiles(this.eslintCmdFileExpr)) } if (results?.length) { console.log(formatter.format(results)) const { errorCount, fatalErrorCount } = countErrors(results) if (fatalErrorCount) { exit(FATAL_ERROR) } if (errorCount) { exit(GENERAL_ERROR) } } } catch (err) { // Report identically to ESLint CLI if (typeof err.messageTemplate === 'string') { try { const eslintBase = `${require.resolve('eslint').split('eslint')[0]}/eslint` const template = require(path.join(eslintBase, `messages/${err.messageTemplate}.js`)) console.log(template(err.messageData || {})) } catch { // Ignore template error then fallback to use `error.stack`. console.log(err.stack) } } exit(GENERAL_ERROR) } } async #overwriteRuleSeverities() { const rules = this.#getConfigContents().rules ?? {} // Allow recommended plugin rules to be overwritten // by user by adding rule to cmd line (because of precedence) const pluginRules = require(await this.#getPluginPath()).configs.recommended.rules ?? {} for (const [name, rule] of Object.entries(rules)) { if (rule && rule !== pluginRules[name] || isfile(path.relative('.', '.eslint', 'rules', name))) { pluginRules[name] = rule this.ruleOpts[name] = rule this.pluginRules = pluginRules } } } } module.exports = Linter