Skip to content

Commit

Permalink
feat: report to Sonar
Browse files Browse the repository at this point in the history
  • Loading branch information
Ni55aN committed Aug 30, 2024
1 parent 945cd71 commit 0f90c0d
Show file tree
Hide file tree
Showing 20 changed files with 320 additions and 68 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.vscode
node_modules
bin
coverage
coverage
.rete-cli
.sonar
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ node_modules
.github
CODE_OF_CONDUCT.md
CONTRIBUTING.md
.rete-cli
.sonar
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default tseslint.config(
{
rules: {
'no-console': 'off',
'no-undefined': 'off',
'@typescript-eslint/no-require-imports': 'off',
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/build/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function buildDev(name: string, config: RollupOptions | RollupOptio
// eslint-disable-next-line max-statements
watcher.on('event', e => {
if (e.code === 'START') {
void safeExec(() => lint(false, true), messages.lintingFail)
void safeExec(() => lint({ fix: false, quiet: true, output: ['stdout'] }), messages.lintingFail)
void safeExec(() => generateTypes(outputDirectories), messages.typesFail)
} else if (e.code === 'BUNDLE_START') {
const index = getIndex(config, e.output)
Expand Down
2 changes: 1 addition & 1 deletion src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function build(config: ReteConfig, pkg: Pkg, outputDirectories: string[])

await safeExec(() => generateTypes(outputDirectories), messages.typesFail, 1)
console.log(messages.typesSuccess)
await safeExec(lint, messages.lintingFail, 1)
await safeExec(() => lint({ output: ['stdout'] }), messages.lintingFail, 1)
console.log(messages.lintingSuccess)

const targets = getRollupConfig(config, outputs, pkg, outputDirectories)
Expand Down
1 change: 1 addition & 0 deletions src/consts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

export const SOURCE_FOLDER = 'src'
export const TEST_FOLDER = 'test'
export const RESULTS_FOLDER = '.rete-cli'
14 changes: 11 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { createCommand } from 'commander'
import { createCommand, Option } from 'commander'

import build from './build'
import { setVerbose } from './build/utils'
Expand Down Expand Up @@ -28,8 +28,16 @@ program
.command('lint')
.description('Lint using ESLint and TS parser')
.option('--fix')
.action(async (options: { fix?: boolean }) => {
await lint(options.fix)
.addOption(new Option('--output <output...>', 'Output target')
.choices(['sonar', 'stdout'])
.default('stdout'))
.option('--quiet')
.action(async (options: { fix?: boolean, quiet?: boolean, output: ('sonar' | 'stdout')[] }) => {
await lint({
fix: options.fix,
quiet: options.quiet,
output: options.output
})
})

program
Expand Down
9 changes: 7 additions & 2 deletions src/lint/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { LintResult } from './results'
import { LintResult, RuleMeta } from './results'

export interface LinterResponse {
rules: RuleMeta[]
results: LintResult[]
}

export interface BaseLinter {
run(): LintResult[] | Promise<LintResult[]>
run(): LinterResponse | Promise<LinterResponse>
}
36 changes: 25 additions & 11 deletions src/lint/eslint/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { loadESLint } from 'eslint'

import { BaseLinter } from '../base'
import { LintMessage, LintResult } from '../results'
import { LintMessage, RuleMeta } from '../results'

export class ESLint implements BaseLinter {
constructor(private options: { targets: string[], fix?: boolean }) {}

async run(): Promise<LintResult[]> {
async run() {
const originESLint = await loadESLint({
useFlatConfig: true
})
Expand All @@ -16,29 +16,43 @@ export class ESLint implements BaseLinter {
errorOnUnmatchedPattern: false
})

const results = await instance.lintFiles(this.options.targets)
const eslintResults = await instance.lintFiles(this.options.targets)

if (this.options.fix) {
await originESLint.outputFixes(results)
await originESLint.outputFixes(eslintResults)
}

return results.map(({ filePath, messages }) => {
const results = eslintResults.map(({ filePath, messages }) => {
return {
filePath,
messages: messages.map(message => {
const result: LintMessage = {
return {
column: message.column,
line: message.line,
endColumn: message.endColumn ?? message.column,
endLine: message.endLine ?? message.line,
endColumn: message.endColumn,
endLine: message.endLine,
ruleId: message.ruleId ?? null,
message: message.message,
severity: message.severity
}

return result
} satisfies LintMessage
})
}
})
const allMessages = eslintResults.flatMap(({ messages }) => messages)
const rules = Object.entries(instance.getRulesMetaForResults(eslintResults))
.map(([id, { docs }]) => {
const firstMessage = allMessages.find(({ ruleId }) => ruleId === id)

return {
id,
description: docs?.description,
severity: firstMessage?.severity ?? 1
} satisfies RuleMeta
})

return {
results,
rules
}
}
}
6 changes: 3 additions & 3 deletions src/lint/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { lint } from './linter'
import { lint, LintOptions } from './linter'

export default async function (fix?: boolean, quiet?: boolean) {
export default async function (options: LintOptions) {
try {
await lint(fix, quiet)
await lint(options)
} catch (e) {
console.error(e)
process.exit(1)
Expand Down
55 changes: 39 additions & 16 deletions src/lint/linter.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,56 @@
import fs from 'fs'
import { join } from 'path'

import { SOURCE_FOLDER, TEST_FOLDER } from '../consts'
import { RESULTS_FOLDER, SOURCE_FOLDER, TEST_FOLDER } from '../consts'
import { ESLint } from './eslint'
import { Formatter } from './formatter'
import { LinterRunner } from './runner'
import { toSonar } from './sonar'
import { TypeCheck } from './type-check'
import { TypeCoverage } from './type-coverage'

export async function lint(fix?: boolean, quiet?: boolean) {
export interface LintOptions {
fix?: boolean
quiet?: boolean
output: ('stdout' | 'sonar')[]
}

// eslint-disable-next-line max-statements
export async function lint(options: LintOptions) {
const src = join(process.cwd(), SOURCE_FOLDER)
const test = join(process.cwd(), TEST_FOLDER)

if (options.output.length === 0) throw new Error('At least one output target must be specified')

const runner = new LinterRunner()

runner.addLinter(new ESLint({ targets: [src, test], fix }))
runner.addLinter(new ESLint({ targets: [src, test], fix: options.fix }))
runner.addLinter(new TypeCoverage({ src }))
runner.addLinter(new TypeCheck({ config: 'tsconfig.json' }))

const results = await runner.run()
const errorResults = results.map(result => {
return {
...result,
messages: result.messages.filter(message => message.severity === 2)
}
})
const formatter = new Formatter()
const resultText = await formatter.format(quiet
? errorResults
: results)

console.log(resultText)
const { results, rules } = await runner.run()

if (options.output.includes('sonar')) {
const fileContent = JSON.stringify(toSonar(results, rules), null, 2)
const filePath = join(process.cwd(), RESULTS_FOLDER, 'sonar.json')

await fs.promises.mkdir(join(process.cwd(), RESULTS_FOLDER), { recursive: true })
await fs.promises.writeFile(filePath, fileContent)
}

if (options.output.includes('stdout')) {
const errorResults = results.map(result => {
return {
...result,
messages: result.messages.filter(message => message.severity === 2)
}
})

const formatter = new Formatter()
const resultText = await formatter.format(options.quiet
? errorResults
: results)

console.log(resultText)
}
}
20 changes: 18 additions & 2 deletions src/lint/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export type Severity = 1 | 2
export interface LintMessage {
column: number
line: number
endColumn: number
endLine: number
endColumn?: number
endLine?: number
ruleId: string | null
message: string
severity: Severity
Expand All @@ -17,6 +17,12 @@ export interface LintResult {
messages: LintMessage[]
}

export interface RuleMeta {
id: string
description?: string
severity: Severity
}

export function makeRelativePath<T extends LintResult>(result: T): T {
return {
...result,
Expand All @@ -37,3 +43,13 @@ export function mergeResults<T extends LintResult>(results: T[]): T[] {
return acc
}, [])
}

export function mergeRules(rules: RuleMeta[]): RuleMeta[] {
return rules.reduce<RuleMeta[]>((acc, rule) => {
const existingRule = acc.find(r => r.id === rule.id)

if (existingRule) return acc

return [...acc, rule]
}, [])
}
11 changes: 8 additions & 3 deletions src/lint/runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BaseLinter } from './base'
import { LintResult, makeRelativePath, mergeResults } from './results'
import { LintResult, makeRelativePath, mergeResults, mergeRules, RuleMeta } from './results'

export class LinterRunner {
private linters: BaseLinter[] = []
Expand All @@ -10,13 +10,18 @@ export class LinterRunner {

async run() {
const results: LintResult[] = []
const rules: RuleMeta[] = []

for (const linter of this.linters) {
const linterResults = await linter.run()

results.push(...linterResults)
results.push(...linterResults.results)
rules.push(...linterResults.rules)
}

return mergeResults(results.map(makeRelativePath))
return {
results: mergeResults(results.map(makeRelativePath)),
rules: mergeRules(rules)
}
}
}
84 changes: 84 additions & 0 deletions src/lint/sonar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { LintMessage, LintResult, RuleMeta } from './results'

interface Rule {
id: string
name: string
description?: string
engineId: string
cleanCodeAttribute: 'FORMATTED' | 'CONVENTIONAL'
impacts: {
softwareQuality: 'MAINTAINABILITY' | 'RELIABILITY' | 'SECURITY'
severity: 'HIGH' | 'MEDIUM' | 'LOW'
}[]
}

interface Location {
message: string
filePath: string
textRange: {
startLine: number
startColumn?: number
endLine?: number
endColumn?: number
}
}

interface Issue {
ruleId: string
effortMinutes?: number
primaryLocation: Location
secondaryLocations?: Location[]
}

export interface Sonar {
rules: Rule[]
issues: Issue[]
}

interface MessageWithRuleId {
filePath: string
message: LintMessage & { ruleId: string }
}

export function toSonar(results: LintResult[], rules: RuleMeta[]): Sonar {
const issues = results
.flatMap(result => result.messages.map(message => ({ message, filePath: result.filePath })))
.filter((issue): issue is MessageWithRuleId => issue.message.ruleId !== null)

return {
issues: issues.map(issue => {
const { filePath, message } = issue

return {
ruleId: message.ruleId,
primaryLocation: {
message: message.message,
filePath,
textRange: {
startLine: message.line,
startColumn: message.column - 1,
endLine: message.endLine,
endColumn: message.endColumn === undefined
? undefined
: message.endColumn - 1
}
}
} satisfies Issue
}),
rules: rules.map(rule => {
return {
id: rule.id,
name: rule.id,
description: rule.description,
engineId: 'rete-cli',
cleanCodeAttribute: 'FORMATTED',
impacts: [{
softwareQuality: 'MAINTAINABILITY',
severity: rule.severity === 2
? 'HIGH'
: 'MEDIUM'
}]
} satisfies Rule
})
}
}
Loading

0 comments on commit 0f90c0d

Please sign in to comment.