diff --git a/.cherry.cjs b/.cherry.cjs index c4439dbe..cabde7d5 100644 --- a/.cherry.cjs +++ b/.cherry.cjs @@ -15,15 +15,5 @@ module.exports = { name: 'TODO', pattern: /TODO/, }, - { - name: '[TS Migration] TS lines of code', - include: TS_FILES, - groupByFile: true, - }, - { - name: '[TS Migration] JS lines of code', - include: JS_FILES, - groupByFile: true, - }, ], } diff --git a/.github/workflows/cherry_diff.yml b/.github/workflows/cherry_diff.yml index 66aa4005..bbae924c 100644 --- a/.github/workflows/cherry_diff.yml +++ b/.github/workflows/cherry_diff.yml @@ -19,4 +19,4 @@ jobs: - name: Raise if new JavaScript code is added # This command will fail if the number of lines of code in JavaScript files has increased # in the current branch compared to the base branch, encouraging developers to contribute to migrating to TS. - run: npm run cherry -- diff --metric='TODO' --error-if-increase --quiet + run: npm run cherry -- diff --metric='[loc] JavaScript' --error-if-increase --quiet diff --git a/bin/commands/backfill.js b/bin/commands/backfill.ts similarity index 92% rename from bin/commands/backfill.js rename to bin/commands/backfill.ts index e12f5600..148788f8 100755 --- a/bin/commands/backfill.js +++ b/bin/commands/backfill.ts @@ -9,6 +9,7 @@ import { getConfiguration } from '../../src/configuration.js' import { getFiles } from '../../src/files.js' import { panic } from '../../src/error.js' +// @ts-expect-error TODO: properly type this export default function (program) { program .command('backfill') @@ -17,11 +18,14 @@ export default function (program) { .option('--until ', 'the date at which the backfill will stop as yyyy-mm-dd (defaults to today)') .option('--interval ', 'the number of days between backfills (defaults to 30)') .option('--quiet', 'reduce output to a minimum') + // @ts-expect-error TODO: properly type this .action(async (options) => { const since = options.since ? new Date(options.since) : substractDays(new Date(), 90) const until = options.until ? new Date(options.until) : new Date() const interval = options.interval ? parseInt(options.interval) : 30 + // @ts-expect-error TODO: properly type this if (isNaN(since)) panic('Invalid since date') + // @ts-expect-error TODO: properly type this if (isNaN(until)) panic('Invalid until date') if (since > until) panic('The since date must be before the until date') const initialBranch = await git.branchName() @@ -42,11 +46,10 @@ export default function (program) { await git.checkout(sha) - const files = await getFiles() const codeOwners = new Codeowners() const occurrences = await findOccurrences({ configuration, - files, + filePaths: await getFiles(), codeOwners, quiet: options.quiet, }) diff --git a/bin/commands/diff.js b/bin/commands/diff.js index e0030683..d71eb6ee 100755 --- a/bin/commands/diff.js +++ b/bin/commands/diff.js @@ -35,7 +35,7 @@ export default function (program) { // Start by calculating the occurrences for the current branch const currentOccurrences = await findOccurrences({ configuration, - files: await getFiles(), + filePaths: await getFiles(), metricNames, codeOwners: new Codeowners(), quiet: options.quiet, @@ -49,7 +49,7 @@ export default function (program) { await git.checkout(baseBranchCommit) previousOccurrences = await findOccurrences({ configuration, - files: await getFiles(), + filePaths: await getFiles(), metricNames, codeOwners: new Codeowners(), quiet: options.quiet, diff --git a/bin/commands/push.js b/bin/commands/push.js index 7459d5bb..6814343c 100755 --- a/bin/commands/push.js +++ b/bin/commands/push.js @@ -40,7 +40,7 @@ export default function (program) { await git.checkout(`${sha}~`) const previousOccurrences = await findOccurrences({ configuration, - files: await getFiles(), + filePaths: await getFiles(), codeOwners: new Codeowners(), quiet: options.quiet, }) diff --git a/bin/commands/run.js b/bin/commands/run.js index 5ef22d2b..3a63190c 100755 --- a/bin/commands/run.js +++ b/bin/commands/run.js @@ -32,11 +32,11 @@ export default function (program) { const owners = options.owner const quiet = options.quiet - const files = owners ? await getFiles(owners, codeOwners) : await getFiles() + const filePaths = owners ? await getFiles(owners, codeOwners) : await getFiles() const occurrences = await findOccurrences({ configuration, - files, + filePaths, metricNames: options.metric, codeOwners, quiet, diff --git a/bin/helpers.js b/bin/helpers.ts similarity index 74% rename from bin/helpers.js rename to bin/helpers.ts index aa888c9c..e6fbcd76 100755 --- a/bin/helpers.js +++ b/bin/helpers.ts @@ -1,3 +1,5 @@ +import { Contribution, EvalMetric, Metric, Occurrence } from '../src/types.js' + import Spinnies from 'spinnies' import _ from 'lodash' import axios from 'axios' @@ -11,7 +13,7 @@ export const API_BASE_URL = process.env.API_URL ?? 'https://www.cherrypush.com/a export const UPLOAD_BATCH_SIZE = 1000 -export const countByMetric = (occurrences) => +export const countByMetric = (occurrences: Occurrence[]) => _(occurrences) .groupBy('metricName') .mapValues((occurrences) => @@ -19,10 +21,12 @@ export const countByMetric = (occurrences) => ) .value() -const handleApiError = async (callback) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handleApiError = async (callback: any) => { try { return await callback() - } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { if (error.response) throw new Error( `❌ Error while calling cherrypush.com API ${error.response.status}: ${ @@ -33,7 +37,7 @@ const handleApiError = async (callback) => { } } -export const buildMetricsPayload = (occurrences) => +export const buildMetricsPayload = (occurrences: Occurrence[]) => _(occurrences) .groupBy('metricName') .mapValues((occurrences, metricName) => ({ @@ -44,7 +48,15 @@ export const buildMetricsPayload = (occurrences) => .flatten() .value() -export const uploadContributions = async (apiKey, projectName, authorName, authorEmail, sha, date, contributions) => +export const uploadContributions = async ( + apiKey: string, + projectName: string, + authorName: string, + authorEmail: string, + sha: string, + date: Date, + contributions: Contribution[] +) => handleApiError(() => axios .post( @@ -55,7 +67,14 @@ export const uploadContributions = async (apiKey, projectName, authorName, autho .then(({ data }) => data) ) -const buildContributionsPayload = (projectName, authorName, authorEmail, sha, date, contributions) => ({ +const buildContributionsPayload = ( + projectName: string, + authorName: string, + authorEmail: string, + sha: string, + date: Date, + contributions: Contribution[] +) => ({ project_name: projectName, author_name: authorName, author_email: authorEmail, @@ -67,7 +86,7 @@ const buildContributionsPayload = (projectName, authorName, authorEmail, sha, da })), }) -export const upload = async (apiKey, projectName, date, occurrences) => { +export const upload = async (apiKey: string, projectName: string, date: Date, occurrences: Occurrence[]) => { if (!projectName) panic('specify a project_name in your cherry.js configuration file before pushing metrics') const uuid = await v4() @@ -101,7 +120,8 @@ export const upload = async (apiKey, projectName, date, occurrences) => { }) ) ) - } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { spinnies.fail('batches', { text: `Batch ${index + 1} out of ${occurrencesBatches.length}: ${error.message}`, }) @@ -109,7 +129,19 @@ export const upload = async (apiKey, projectName, date, occurrences) => { } } -const buildPushPayload = ({ apiKey, projectName, uuid, date, occurrences }) => ({ +const buildPushPayload = ({ + apiKey, + projectName, + uuid, + date, + occurrences, +}: { + apiKey: string + projectName: string + uuid: string + date: Date + occurrences: Occurrence[] +}) => ({ api_key: apiKey, project_name: projectName, date: date.toISOString(), @@ -117,7 +149,7 @@ const buildPushPayload = ({ apiKey, projectName, uuid, date, occurrences }) => ( metrics: buildMetricsPayload(occurrences), }) -export const buildSarifPayload = (projectName, branch, sha, occurrences) => { +export const buildSarifPayload = (projectName: string, branch: string, sha: string, occurrences: Occurrence[]) => { const rules = _(occurrences) .groupBy('metricName') .map((occurrences) => ({ @@ -168,7 +200,7 @@ export const buildSarifPayload = (projectName, branch, sha, occurrences) => { } } -export const buildSonarGenericImportPayload = (occurrences) => ({ +export const buildSonarGenericImportPayload = (occurrences: Occurrence[]) => ({ issues: occurrences.map((occurrence) => ({ engineId: 'cherry', ruleId: occurrence.metricName, @@ -184,4 +216,8 @@ export const buildSonarGenericImportPayload = (occurrences) => ({ })), }) -export const sortObject = (object) => _(object).toPairs().sortBy(0).fromPairs().value() +export const sortObject = (object: object) => _(object).toPairs().sortBy(0).fromPairs().value() + +export function isEvalMetric(metric: Metric): metric is EvalMetric { + return 'eval' in metric +} diff --git a/package-lock.json b/package-lock.json index b6c713ba..bda34474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,9 @@ "cherry": "dist/bin/cherry.js" }, "devDependencies": { + "@types/lodash": "^4.17.7", + "@types/spinnies": "^0.5.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "8.x.x", "@typescript-eslint/parser": "8.x.x", "eslint": "^8.54.0", @@ -1009,6 +1012,12 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, "node_modules/@types/node": { "version": "22.5.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", @@ -1020,6 +1029,18 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/spinnies": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@types/spinnies/-/spinnies-0.5.3.tgz", + "integrity": "sha512-HYrOubG2TVgRQRKcW1HJ/1eJIIBpLqDoJo551McJgWdO8xzxnaxu/bPKdqC/7okoEy4ZZjy3I4/DwK1sz2OCog==", + "dev": true + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", diff --git a/package.json b/package.json index ea34be99..08f22cb4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "sh test/fixtures/setup.sh && vitest run", "test:setup": "sh test/fixtures/setup.sh", "test:cleanup": "sh test/fixtures/cleanup.sh", + "type-check": "tsc --noEmit", "format": "prettier --write ." }, "repository": { @@ -53,6 +54,9 @@ "uuid": "^9.0.1" }, "devDependencies": { + "@types/lodash": "^4.17.7", + "@types/spinnies": "^0.5.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "8.x.x", "@typescript-eslint/parser": "8.x.x", "eslint": "^8.54.0", @@ -60,9 +64,9 @@ "husky": "^9.0.11", "lint-staged": "^15.2.10", "prettier": "^3.3.3", + "tsx": "^4.19.0", "typescript": "5.5.4", - "vitest": "^2.0.5", - "tsx": "^4.19.0" + "vitest": "^2.0.5" }, "lint-staged": { "*": "prettier --ignore-unknown --write", diff --git a/src/contributions.js b/src/contributions.ts similarity index 68% rename from src/contributions.js rename to src/contributions.ts index 704b2123..52f241fa 100644 --- a/src/contributions.js +++ b/src/contributions.ts @@ -1,16 +1,18 @@ +import { Contribution, Occurrence } from './types.js' + import _ from 'lodash' -const toCountByMetricName = (occurrences) => +const toCountByMetricName = (occurrences: Occurrence[]) => _.mapValues(_.groupBy(occurrences, 'metricName'), (occurrences) => _.sum(occurrences.map((occurrence) => occurrence.value || 1)) ) -export const computeContributions = (occurrences, previousOccurrences) => { +export const computeContributions = (occurrences: Occurrence[], previousOccurrences: Occurrence[]) => { const counts = toCountByMetricName(occurrences) const previousCounts = toCountByMetricName(previousOccurrences) const metrics = _.uniq(Object.keys(counts).concat(Object.keys(previousCounts))) - const contributions = [] + const contributions: Contribution[] = [] metrics.forEach((metric) => { const diff = (counts[metric] || 0) - (previousCounts[metric] || 0) if (diff !== 0) contributions.push({ metricName: metric, diff }) diff --git a/src/date.js b/src/date.ts similarity index 51% rename from src/date.js rename to src/date.ts index cff0627b..50921bf8 100644 --- a/src/date.js +++ b/src/date.ts @@ -1,14 +1,18 @@ -export const toISODate = (date) => date.toISOString().split('T')[0] -export const substractDays = (date, count) => { +export const toISODate = (date: Date) => date.toISOString().split('T')[0] + +export const substractDays = (date: Date, count: number) => { date.setDate(date.getDate() - count) return date } -export const addDays = (date, count) => { + +export const addDays = (date: Date, count: number) => { date.setDate(date.getDate() + count) return date } -export const firstDayOfMonth = (date) => new Date(Date.UTC(date.getFullYear(), date.getMonth(), 1)) -export const nextMonth = (originalDate) => { + +export const firstDayOfMonth = (date: Date) => new Date(Date.UTC(date.getFullYear(), date.getMonth(), 1)) + +export const nextMonth = (originalDate: Date) => { const date = firstDayOfMonth(originalDate) // Avoid returning 1 for getMonth() when day is 31 const [year, month] = date.getMonth() < 11 ? [date.getFullYear(), date.getMonth() + 1] : [date.getFullYear() + 1, 0] diff --git a/src/files.js b/src/files.js index 5c85641b..0d57f63e 100644 --- a/src/files.js +++ b/src/files.js @@ -1,22 +1,23 @@ -import { promises as fs } from 'fs' -import intersection from 'lodash/intersection.js' import * as git from './git.js' -class File { - constructor(path) { - this.path = path - } +import { promises as fs } from 'fs' +import intersection from 'lodash/intersection.js' - async readLines() { - try { - return Buffer.from(await fs.readFile(this.path)) - .toString() - .split(/\r\n|\r|\n/) - } catch (error) { - if (error.code === 'ENOENT') return [] - if (error.code === 'EISDIR') return [] - throw error - } +/** + * Reads the lines from a file at a given path. + * + * @param {string} path - The path to the file. + * @returns {Promise} A promise that resolves to an array of lines in the file, or an empty array if the file doesn't exist or is a directory. + */ +export async function readLines(path) { + try { + const data = await fs.readFile(path) + return Buffer.from(data) + .toString() + .split(/\r\n|\r|\n/) + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'EISDIR') return [] + throw error } } @@ -25,5 +26,5 @@ export const getFiles = async (owners, codeOwners) => { let selectedPaths = allPaths if (owners) selectedPaths = intersection(codeOwners.getFiles(owners), selectedPaths) - return selectedPaths.map((path) => new File(path)) + return selectedPaths } diff --git a/src/git.js b/src/git.ts similarity index 78% rename from src/git.js rename to src/git.ts index e994de3a..3fc90038 100644 --- a/src/git.js +++ b/src/git.ts @@ -2,7 +2,7 @@ import { CONFIG_FILE_LOCAL_PATHS } from './configuration.js' import sh from './sh.js' import { toISODate } from './date.js' -export const git = async (cmd) => { +export const git = async (cmd: string): Promise => { const { stdout } = await sh(`git ${cmd}`) return stdout.toString().split('\n').filter(Boolean) } @@ -30,7 +30,7 @@ export const getRemoteUrl = async () => { * Guesses the project name based on the remote URL of the git repository. * If the remote URL is not found, returns an empty string. */ -export const guessProjectName = (remoteUrl) => { +export const guessProjectName = (remoteUrl: string) => { if (!remoteUrl) return null // Handle https remotes, such as in https://github.com/cherrypush/cherry-cli.git @@ -53,19 +53,19 @@ export const getDefaultBranchName = async () => { return defaultBranch.replace('origin/', '').trim() } -export const getMergeBase = async (currentBranchName, defaultBranchName) => +export const getMergeBase = async (currentBranchName: string, defaultBranchName: string) => (await git(`merge-base ${currentBranchName} origin/${defaultBranchName}`)).toString().trim() -export const authorName = async (sha) => (await git(`show ${sha} --format=%an --no-patch`))[0] +export const authorName = async (sha: string) => (await git(`show ${sha} --format=%an --no-patch`))[0] -export const authorEmail = async (sha) => (await git(`show ${sha} --format=%ae --no-patch`))[0] +export const authorEmail = async (sha: string) => (await git(`show ${sha} --format=%ae --no-patch`))[0] -export const commitDate = async (sha) => new Date((await git(`show -s --format=%ci ${sha}`))[0]) +export const commitDate = async (sha: string) => new Date((await git(`show -s --format=%ci ${sha}`))[0]) -export const commitShaAt = async (date, branch) => +export const commitShaAt = async (date: Date, branch: string) => (await git(`rev-list --reverse --after=${toISODate(date)} ${branch}`))[0] -export const checkout = async (sha) => { +export const checkout = async (sha: string) => { console.log(`Checking out ${sha}`) await git(`checkout ${sha}`) } diff --git a/src/helpers/timer.ts b/src/helpers/timer.ts index c8814932..8285f07f 100644 --- a/src/helpers/timer.ts +++ b/src/helpers/timer.ts @@ -4,10 +4,6 @@ const timers: Record = {} /** * Executes a provided function block and measures its execution time. - * Logs a message if the execution time exceeds 2 seconds. - * - * @param {Function} codeBlock - The block of code to execute. - * @returns {*} The result of the executed code block. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function executeWithTiming(codeBlock: any, identifier: string) { diff --git a/src/occurrences.js b/src/occurrences.ts similarity index 68% rename from src/occurrences.js rename to src/occurrences.ts index cfbda86e..d087370b 100644 --- a/src/occurrences.js +++ b/src/occurrences.ts @@ -1,9 +1,11 @@ +import { Configuration, EvalMetric, Metric, Occurrence, PatternMetric, PluginName, Plugins } from './types.js' import { executeWithTiming, warnsAboutLongRunningTasks } from './helpers/timer.js' import Spinnies from 'spinnies' import _ from 'lodash' import { buildPermalink } from './permalink.js' import eslint from './plugins/eslint.js' +import { isEvalMetric } from '../bin/helpers.js' import jsCircularDependencies from './plugins/js_circular_dependencies.js' import jsUnimported from './plugins/js_unimported.js' import loc from './plugins/loc.js' @@ -11,6 +13,7 @@ import minimatch from 'minimatch' import npmOutdated from './plugins/npm_outdated.js' import pLimit from 'p-limit' import { panic } from './error.js' +import { readLines } from './files.js' import rubocop from './plugins/rubocop.js' import yarnOutdated from './plugins/yarn_outdated.js' @@ -26,8 +29,9 @@ const PLUGINS = { yarnOutdated, } -const minimatchCache = {} -const matchPattern = (path, patternOrPatterns) => { +const minimatchCache: Record = {} + +const matchPattern = (path: string, patternOrPatterns: string | string[]) => { const patterns = Array.isArray(patternOrPatterns) ? patternOrPatterns : [patternOrPatterns] return patterns.some((pattern) => { @@ -38,28 +42,31 @@ const matchPattern = (path, patternOrPatterns) => { }) } -const findFileOccurences = async (file, metrics) => { +const findFileOccurences = async (filePath: string, metrics: PatternMetric[]) => { const relevantMetrics = metrics.filter((metric) => { - const pathIncluded = metric.include ? matchPattern(file.path, metric.include) : true - const pathExcluded = metric.exclude ? matchPattern(file.path, metric.exclude) : false + const pathIncluded = metric.include ? matchPattern(filePath, metric.include) : true + const pathExcluded = metric.exclude ? matchPattern(filePath, metric.exclude) : false return pathIncluded && !pathExcluded }) if (!relevantMetrics.length) return [] - const occurrencesByMetric = {} - const lines = await file.readLines() + const occurrencesByMetric: Record = {} + const lines = await readLines(filePath) lines.forEach((line, lineIndex) => { relevantMetrics.forEach((metric) => { + // @ts-expect-error TODO: check if we can pass an empty string instead of undefined or skip this step entirely if no pattern was provided if (!line.match(metric.pattern)) return occurrencesByMetric[metric.name] ||= [] occurrencesByMetric[metric.name].push({ - path: file.path, + path: filePath, lineNumber: lineIndex + 1, }) }) }) + // @ts-expect-error TODO: properly type this return Object.entries(occurrencesByMetric).flatMap(([metricName, occurrences]) => { + // @ts-expect-error TODO: properly type this const groupByFile = metrics.find((metric) => metric.name === metricName).groupByFile return groupByFile @@ -83,7 +90,7 @@ const findFileOccurences = async (file, metrics) => { }) } -const matchPatterns = (files, metrics, quiet) => { +const matchPatterns = async (files: string[], metrics: PatternMetric[], quiet: boolean): Promise => { if (!files.length || !metrics.length) return [] if (!quiet) spinnies.add('patterns', { text: 'Matching patterns...', indent: 2 }) @@ -100,7 +107,8 @@ const matchPatterns = (files, metrics, quiet) => { return promise } -const runEvals = (metrics, codeOwners, quiet) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const runEvals = async (metrics: EvalMetric[], codeOwners: any, quiet: boolean): Promise => { if (!metrics.length) return [] if (!quiet) spinnies.add('evals', { text: 'Running eval()...', indent: 2 }) @@ -114,65 +122,84 @@ const runEvals = (metrics, codeOwners, quiet) => { }) } - const occurrences = await executeWithTiming( + // TODO: properly type executeWithTiming and remove the cast + const occurrences = (await executeWithTiming( async () => await metric.eval({ codeOwners }), `Metric '${metric.name}'` - ) - const result = occurrences.map((occurrence) => ({ ...occurrence, metricName: metric.name })) + )) as Occurrence[] + + const result = occurrences.map((occurrence) => ({ ...occurrence, metricName: metric.name }) as Occurrence) if (!quiet) spinnies.succeed(`metric_${metric.name}`, { text: metric.name }) return result }) ) + if (!quiet) promise.then(() => spinnies.succeed('evals', { text: 'Running eval()' })) return promise } -const runPlugins = async (plugins = {}, quiet) => { +const runPlugins = async (plugins: Plugins = {}, quiet: boolean): Promise => { if (typeof plugins !== 'object' || plugins === null) panic('Plugins should be an object') if (!Object.keys(plugins).length) return [] if (!quiet) spinnies.add('plugins', { text: 'Running plugins...', indent: 2 }) + const promise = Promise.all( Object.entries(plugins).map(async ([name, options]) => { - const plugin = PLUGINS[name] + const plugin = PLUGINS[name as PluginName] if (!plugin) panic(`Unsupported '${name}' plugin\nExpected one of: ${Object.keys(PLUGINS).join(', ')}`) if (!quiet) spinnies.add(`plugin_${name}`, { text: `${name}...`, indent: 4 }) + // @ts-expect-error TODO: properly type plugin options const result = executeWithTiming(async () => await plugin.run(options), `Plugin '${name}'`) if (!quiet) spinnies.succeed(`plugin_${name}`, { text: name }) return result }) ) + if (!quiet) promise.then(() => spinnies.succeed('plugins', { text: 'Running plugin' })) return promise } -export const emptyMetric = (metricName) => ({ +export const emptyMetric = (metricName: string) => ({ metricName, text: 'No occurrences', value: 0, }) -const withEmptyMetrics = (occurrences, metrics = []) => { - const occurrencesByMetric = _.groupBy(occurrences, 'metricName') - const allMetricNames = _.uniq(metrics.map((metric) => metric.name).concat(Object.keys(occurrencesByMetric))) +const withEmptyMetrics = (occurrences: Occurrence[], metrics: Metric[] = []) => { + const occurrencesByMetric: Record = _.groupBy(occurrences, 'metricName') + const allMetricNames: string[] = _.uniq(metrics.map((metric) => metric.name).concat(Object.keys(occurrencesByMetric))) return allMetricNames.map((metricName) => occurrencesByMetric[metricName] || [emptyMetric(metricName)]).flat() } -export const findOccurrences = async ({ configuration, files, metricNames, codeOwners, quiet }) => { +export const findOccurrences = async ({ + configuration, + filePaths, + metricNames, + codeOwners, + quiet, +}: { + configuration: Configuration + filePaths: string[] + metricNames?: string[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + codeOwners: any + quiet: boolean +}) => { let metrics = configuration.metrics const { project_name: projectName, permalink } = configuration // Prevent running all metrics if a subset is provided if (metricNames) metrics = metrics.filter(({ name }) => metricNames.includes(name)) - // Separate metrics into eval and file metrics - const [evalMetrics, fileMetrics] = _.partition(metrics, (metric) => metric.eval) + // Separate metrics into eval and pattern metrics + const [evalMetrics, patternMetrics] = _.partition(metrics, isEvalMetric) const result = await Promise.all([ - matchPatterns(files, fileMetrics, quiet), + matchPatterns(filePaths, patternMetrics, quiet), runEvals(evalMetrics, codeOwners, quiet), runPlugins(configuration.plugins, quiet), ]) diff --git a/src/sh.js b/src/sh.ts similarity index 77% rename from src/sh.js rename to src/sh.ts index b42380bc..6d29f5ed 100644 --- a/src/sh.js +++ b/src/sh.ts @@ -2,7 +2,7 @@ import child_process from 'child_process' import { debug } from './log.js' // From https://stackoverflow.com/a/68958420/9847645, to avoid 200Kb limit causing ENOBUFS errors for large output -const sh = (cmd, { throwOnError = true } = {}) => +const sh = (cmd: string, { throwOnError = true } = {}): Promise<{ stderr: string; stdout: string }> => new Promise((resolve, reject) => { debug('#', cmd) const [command, ...args] = cmd.split(/\s+/) @@ -14,7 +14,7 @@ const sh = (cmd, { throwOnError = true } = {}) => spawnedProcess.stdout.on('data', (chunk) => (stdout += chunk.toString())) spawnedProcess.stderr.on('data', (chunk) => (stderr += chunk.toString())) spawnedProcess.on('close', (code) => { - if (throwOnError && code > 0) return reject(new Error(`${stderr} (Failed Instruction: ${cmd})`)) + if (throwOnError && code && code > 0) return reject(new Error(`${stderr} (Failed Instruction: ${cmd})`)) debug(stdout) resolve({ stderr, stdout }) }) diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..aaebcafe --- /dev/null +++ b/src/types.ts @@ -0,0 +1,42 @@ +export type Occurrence = { + metricName: string + filePath?: string // TODO: it's weird that filePath is optional here, let's review this + text: string + value: number + lineNumber?: number + url?: string + owners?: string[] +} + +export type Contribution = { + metricName: string + diff: number +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Codeowners = any + +export type EvalMetric = { + name: string + eval: (options: { codeOwners: Codeowners }) => Promise +} + +export type PatternMetric = { + name: string + pattern?: RegExp + include?: string | string[] + exclude?: string | string[] + groupByFile?: boolean +} + +export type Metric = EvalMetric | PatternMetric + +export type PluginName = 'loc' | 'jsCircularDependencies' | 'eslint' +export type Plugins = Partial> + +export type Configuration = { + project_name: string + permalink: () => string + metrics: Metric[] + plugins?: Plugins +}