diff --git a/docs/docs/reports.md b/docs/docs/reports.md new file mode 100644 index 00000000..4b1a9f2a --- /dev/null +++ b/docs/docs/reports.md @@ -0,0 +1,15 @@ +--- +sidebar_position: 7 +title: Creating violation reports +displayed_sidebar: tutorialSidebar +--- + +`Sheriff` can generate violation reports in various formats, which can be useful for integrating with CI/CD pipelines or for manual review. The reports can be generated in JSON, JUnit format. + +The reports are generated when `sheriff verify` is executed and `reporters` are configured in the `sheriff.config.ts` file. + +## Defining the reporters +To define the report format, you can use the `reporters` property in your `sheriff.config.ts` file. This property accepts an array of report formats which should be used: `reporters: ['json']` + +## Custom directory where reports are written to +By default reports are written to `./sheriff/reports` relative to the project root. The directory can be customized by setting the `reportsDirectory`- property in the `sheriff.config.ts`. diff --git a/packages/core/src/lib/cli/internal/reporter/json/json-reporter.ts b/packages/core/src/lib/cli/internal/reporter/json/json-reporter.ts new file mode 100644 index 00000000..1dff55ee --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/json/json-reporter.ts @@ -0,0 +1,20 @@ +import { Reporter } from '../reporter'; +import { cli } from '../../../cli'; +import { ProjectViolation } from '../../../project-violation'; +import { saveReport } from '../utils/save-report'; +import { ReporterOptions } from '../utils/reporter-options'; + +export class JsonReporter implements Reporter { + #options: ReporterOptions; + + constructor(options: ReporterOptions) { + this.#options = options; + } + + createReport(validationResults: ProjectViolation): void { + cli.log(`Creating JSON report`); + + const content = JSON.stringify(validationResults, null, 2); + saveReport(this.#options, 'violations', '.json', content); + } +} diff --git a/packages/core/src/lib/cli/internal/reporter/junit/internal/junit-report-builder.ts b/packages/core/src/lib/cli/internal/reporter/junit/internal/junit-report-builder.ts new file mode 100644 index 00000000..15999538 --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/junit/internal/junit-report-builder.ts @@ -0,0 +1,115 @@ +interface TestCase { + modulePath: string; + name: string; + failureMessage: string; + fromTag?: string; + toTags?: string; + fromModulePath?: string; + toModulePath?: string; +} + +interface TestSuiteOptions { + name: string; + totalDependencyRulesViolations: number; + totalEncapsulationViolations: number; + totalViolatedFiles: number; + hasError: boolean; +} + +interface TestSuite { + name: string; + totalDependencyRulesViolations: number; + totalEncapsulationViolations: number; + totalViolatedFiles: number; + hasError: boolean; + testCases: TestCase[]; + addTestCase(testCase: TestCase): void; +} + +class JUnitTestSuite implements TestSuite { + name: string; + totalDependencyRulesViolations: number; + totalEncapsulationViolations: number; + totalViolatedFiles: number; + hasError: boolean; + testCases: TestCase[] = []; + + constructor(options: TestSuiteOptions) { + this.name = options.name; + this.totalDependencyRulesViolations = + options.totalDependencyRulesViolations; + this.totalEncapsulationViolations = options.totalEncapsulationViolations; + this.totalViolatedFiles = options.totalViolatedFiles; + this.hasError = options.hasError; + } + + addTestCase(testCase: TestCase): void { + this.testCases.push(testCase); + } +} + +interface JUnitBuilder { + testsuite(options: TestSuiteOptions): TestSuite; + getReport(): string; +} + +class JUnitReportBuilder implements JUnitBuilder { + private testSuites: TestSuite[] = []; + + testsuite(options: TestSuiteOptions): TestSuite { + const suite = new JUnitTestSuite(options); + this.testSuites.push(suite); + return suite; + } + + getReport(): string { + if (this.testSuites.length === 0) { + return ` + +`; + } + + let report = ` +\n`; + + for (const suite of this.testSuites) { + const testSuite = suite as JUnitTestSuite; + + report += ` \n`; + + for (const testCase of testSuite.testCases) { + const attributes = [ + `modulePath="${testCase.modulePath}"`, + `name="${testCase.name}"`, + ]; + + if (testCase.fromTag) { + attributes.push(`fromTag="${testCase.fromTag}"`); + } + if (testCase.toTags) { + attributes.push(`toTags="${testCase.toTags}"`); + } + if (testCase.fromModulePath) { + attributes.push(`fromModulePath="${testCase.fromModulePath}"`); + } + if (testCase.toModulePath) { + attributes.push(`toModulePath="${testCase.toModulePath}"`); + } + + report += ` \n`; + report += ` \n`; + report += ` \n`; + } + + report += ` \n`; + } + + report += ``; + + return report; + } +} + +export function junitBuilder(): JUnitBuilder { + return new JUnitReportBuilder(); +} diff --git a/packages/core/src/lib/cli/internal/reporter/junit/junit-reporter.ts b/packages/core/src/lib/cli/internal/reporter/junit/junit-reporter.ts new file mode 100644 index 00000000..26be5247 --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/junit/junit-reporter.ts @@ -0,0 +1,64 @@ +import { ProjectViolation } from '../../../project-violation'; +import { cli } from '../../../cli'; +import { Reporter } from '../reporter'; +import { junitBuilder } from './internal/junit-report-builder'; +import { saveReport } from '../utils/save-report'; +import { ReporterOptions } from '../utils/reporter-options'; + +export class JunitReporter implements Reporter { + #options: ReporterOptions; + + constructor(options: ReporterOptions) { + this.#options = options; + } + + createReport(validationResults: ProjectViolation): void { + cli.log(`Creating JUnit report`); + + const xmlContent = this.#generateXml(validationResults); + saveReport(this.#options, 'violations', '.xml', xmlContent); + } + + #generateXml(validationResults: ProjectViolation): string { + const builder = junitBuilder(); + const suite = builder.testsuite({ + name: this.#options.projectName, + totalDependencyRulesViolations: + validationResults.totalDependencyRuleViolations, + totalEncapsulationViolations: + validationResults.totalEncapsulationViolations, + totalViolatedFiles: validationResults.totalViolatedFiles, + hasError: validationResults.hasError, + }); + + // Process each violation + for (const violation of validationResults.violations) { + // Add encapsulation violations + for (const encapsulation of violation.encapsulations) { + suite.addTestCase({ + modulePath: violation.filePath, + name: 'encapsulation', + failureMessage: `${encapsulation} cannot be imported. It is encapsulated.`, + }); + } + + // Add dependency rule violations + for (const depViolation of violation.dependencyRuleViolations) { + const fromModulePath = depViolation.fromModulePath; + const toModulePath = depViolation.toModulePath; + + suite.addTestCase({ + modulePath: violation.filePath, + name: 'dependency-rule', + failureMessage: `module ${fromModulePath} cannot access ${toModulePath}. Tag ${depViolation.fromTag} has no clearance for tags ${depViolation.toTags.join(',')}`, + fromTag: depViolation.fromTag, + toTags: depViolation.toTags.join(','), + fromModulePath: fromModulePath, + toModulePath: toModulePath, + }); + } + } + + return builder.getReport(); + } +} diff --git a/packages/core/src/lib/cli/internal/reporter/reporter-factory.ts b/packages/core/src/lib/cli/internal/reporter/reporter-factory.ts new file mode 100644 index 00000000..3e23851d --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/reporter-factory.ts @@ -0,0 +1,39 @@ +import { JsonReporter } from './json/json-reporter'; +import { Reporter } from './reporter'; +import { JunitReporter } from './junit/junit-reporter'; + +type ReporterConstructor = new (options: { + outputDir: string; + projectName: string; +}) => Reporter; + +const REPORTER_REGISTRY = new Map([ + ['json', JsonReporter], + ['junit', JunitReporter], +]); + +export function reporterFactory(options: { + reporterFormats: string[]; + outputDir: string; + projectName: string; +}): Reporter[] { + const reporters: Reporter[] = []; + + options.reporterFormats.forEach((format) => { + const ReporterClass = REPORTER_REGISTRY.get(format); + if (ReporterClass) { + reporters.push( + new ReporterClass({ + outputDir: options.outputDir, + projectName: options.projectName, + }), + ); + } + }); + + return reporters; +} + +export function getRegisteredFormats(): string[] { + return Array.from(REPORTER_REGISTRY.keys()); +} diff --git a/packages/core/src/lib/cli/internal/reporter/reporter.ts b/packages/core/src/lib/cli/internal/reporter/reporter.ts new file mode 100644 index 00000000..227e579f --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/reporter.ts @@ -0,0 +1,5 @@ +import { ProjectViolation } from '../../project-violation'; + +export interface Reporter { + createReport(validationResults: ProjectViolation): void; +} diff --git a/packages/core/src/lib/cli/internal/reporter/supported-reporter-formats.ts b/packages/core/src/lib/cli/internal/reporter/supported-reporter-formats.ts new file mode 100644 index 00000000..5ca9b3a8 --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/supported-reporter-formats.ts @@ -0,0 +1,5 @@ +import { getRegisteredFormats } from './reporter-factory'; + +export type ReporterFormat = 'json' | 'junit'; + +export const SUPPORTED_REPORTER_FORMATS = getRegisteredFormats(); diff --git a/packages/core/src/lib/cli/internal/reporter/utils/reporter-options.ts b/packages/core/src/lib/cli/internal/reporter/utils/reporter-options.ts new file mode 100644 index 00000000..535116af --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/utils/reporter-options.ts @@ -0,0 +1,4 @@ +export interface ReporterOptions { + outputDir: string; + projectName: string; +} diff --git a/packages/core/src/lib/cli/internal/reporter/utils/save-report.ts b/packages/core/src/lib/cli/internal/reporter/utils/save-report.ts new file mode 100644 index 00000000..a29506a1 --- /dev/null +++ b/packages/core/src/lib/cli/internal/reporter/utils/save-report.ts @@ -0,0 +1,47 @@ +import getFs from '../../../../fs/getFs'; +import { DEFAULT_PROJECT_NAME } from '../../get-entries-from-cli-or-config'; +import { ReporterOptions } from './reporter-options'; + +/** + * Saves content to a file, creating the directory structure if needed + * @param options Reporter options containing outputDir and projectName + * @param fileName The base filename for the report + * @param extension The file extension including the dot + * @param content The content to write to the file + */ +export function saveReport( + options: ReporterOptions, + fileName: string, + extension: string, + content: string, +): void { + const fs = getFs(); + + createReportDirectory(options); + const targetPath = getReportTargetPath(options, fileName, extension); + fs.writeFile(targetPath, content); +} + +function getReportTargetPath( + options: ReporterOptions, + fileName: string, + extension: string, +): string { + const fs = getFs(); + + if (options.projectName === DEFAULT_PROJECT_NAME) { + return fs.join(options.outputDir, fileName + extension); + } + + return fs.join(options.outputDir, options.projectName, fileName + extension); +} + +function createReportDirectory(options: ReporterOptions): void { + const fs = getFs(); + + if (options.projectName === DEFAULT_PROJECT_NAME) { + fs.createDir(options.outputDir); + } else { + fs.createDir(fs.join(options.outputDir, options.projectName)); + } +} diff --git a/packages/core/src/lib/cli/project-violation.ts b/packages/core/src/lib/cli/project-violation.ts new file mode 100644 index 00000000..b542c33d --- /dev/null +++ b/packages/core/src/lib/cli/project-violation.ts @@ -0,0 +1,9 @@ +import { Violation } from './verify'; + +export type ProjectViolation = { + totalDependencyRuleViolations: number; + totalEncapsulationViolations: number; + totalViolatedFiles: number; + hasError: boolean; + violations: Violation[]; +}; diff --git a/packages/core/src/lib/cli/tests/__snapshots__/json-reporter.spec.ts.snap b/packages/core/src/lib/cli/tests/__snapshots__/json-reporter.spec.ts.snap new file mode 100644 index 00000000..d23c3d11 --- /dev/null +++ b/packages/core/src/lib/cli/tests/__snapshots__/json-reporter.spec.ts.snap @@ -0,0 +1,44 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`json reporter > should create a json-file in /.sheriff/project/violations.json 1`] = ` +"{ + "totalEncapsulationViolations": 0, + "totalViolatedFiles": 0, + "totalDependencyRuleViolations": 2, + "hasError": true, + "violations": [ + { + "filePath": "/project/customers/feature/feature.ts", + "encapsulations": [], + "dependencyRules": [], + "dependencyRuleViolations": [ + { + "rawImport": "@eternal/shared/master-data", + "fromModulePath": "/project/customers/feature", + "toModulePath": "/project/shared/master-data", + "fromTag": "domain:customers", + "toTags": [ + "shared:master-data" + ] + } + ] + }, + { + "filePath": "/project/customers/ui/ui.ts", + "encapsulations": [], + "dependencyRules": [], + "dependencyRuleViolations": [ + { + "rawImport": "@eternal/shared/form", + "fromModulePath": "/project/customers/ui", + "toModulePath": "/project/app/shared/form", + "fromTag": "domain:customers", + "toTags": [ + "shared:form" + ] + } + ] + } + ] +}" +`; diff --git a/packages/core/src/lib/cli/tests/__snapshots__/junit-reporter.spec.ts.snap b/packages/core/src/lib/cli/tests/__snapshots__/junit-reporter.spec.ts.snap new file mode 100644 index 00000000..64ebdbd7 --- /dev/null +++ b/packages/core/src/lib/cli/tests/__snapshots__/junit-reporter.spec.ts.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`JUnit reporter > should create a xml-file in /.sheriff/project/violations.xml 1`] = ` +" + + + + + + + + + +" +`; diff --git a/packages/core/src/lib/cli/tests/json-reporter.spec.ts b/packages/core/src/lib/cli/tests/json-reporter.spec.ts new file mode 100644 index 00000000..9d9c6018 --- /dev/null +++ b/packages/core/src/lib/cli/tests/json-reporter.spec.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, it, expect, beforeAll } from 'vitest'; +import { VirtualFs } from '../../fs/virtual-fs'; +import getFs, { useVirtualFs } from '../../fs/getFs'; +import { toFsPath } from '../../file-info/fs-path'; +import { ProjectViolation } from '../project-violation'; +import { JsonReporter } from '../internal/reporter/json/json-reporter'; + +describe('json reporter', () => { + let fs: VirtualFs; + beforeAll(() => { + useVirtualFs(); + fs = getFs() as VirtualFs; + }); + + beforeEach(() => { + fs.reset(); + fs.createDir('/project/customers/feature'); + fs.createDir('/project/shared/master-data'); + fs.createDir('/project/customers/ui'); + fs.createDir('/project/app/shared/form'); + }); + it('should create a json-file in /.sheriff/project/violations.json', () => { + const reporter = new JsonReporter({ + outputDir: '.sheriff', + projectName: 'project', + }); + const violations: ProjectViolation = { + totalEncapsulationViolations: 0, + totalViolatedFiles: 0, + totalDependencyRuleViolations: 2, + hasError: true, + violations: [ + { + filePath: '/project/customers/feature/feature.ts', + encapsulations: [], + dependencyRules: [], + dependencyRuleViolations: [ + { + rawImport: '@eternal/shared/master-data', + fromModulePath: toFsPath('/project/customers/feature'), + toModulePath: toFsPath('/project/shared/master-data'), + fromTag: 'domain:customers', + toTags: ['shared:master-data'], + }, + ], + }, + { + filePath: '/project/customers/ui/ui.ts', + encapsulations: [], + dependencyRules: [], + dependencyRuleViolations: [ + { + rawImport: '@eternal/shared/form', + fromModulePath: toFsPath('/project/customers/ui'), + toModulePath: toFsPath('/project/app/shared/form'), + fromTag: 'domain:customers', + toTags: ['shared:form'], + }, + ], + }, + ], + }; + reporter.createReport(violations); + + expect(fs.readFile('.sheriff/project/violations.json')).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/lib/cli/tests/junit-report-builder.spec.ts b/packages/core/src/lib/cli/tests/junit-report-builder.spec.ts new file mode 100644 index 00000000..300a5520 --- /dev/null +++ b/packages/core/src/lib/cli/tests/junit-report-builder.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { junitBuilder } from '../internal/reporter/junit/internal/junit-report-builder'; + +describe('JUnitReportBuilder', () => { + it('should create a new builder', () => { + const builder = junitBuilder(); + + expect(builder).toBeDefined(); + expect(builder.getReport()).toEqual(` + +`); + }); + it('testsuite() should create a new Testsuite ', () => { + const builder = junitBuilder(); + const suite = builder.testsuite({ + name: 'Example Suite', + totalDependencyRulesViolations: 1, + totalEncapsulationViolations: 2, + totalViolatedFiles: 2, + hasError: true, + }); + const expectedResult = ` + + + +`; + + expect(builder.getReport()).toEqual(expectedResult); + }); + + it('should add a new test case', () => { + const builder = junitBuilder(); + const suite = builder.testsuite({ + name: 'Example Suite', + totalDependencyRulesViolations: 1, + totalEncapsulationViolations: 2, + totalViolatedFiles: 2, + hasError: true, + }); + suite.addTestCase({ + modulePath: 'src/utils.ts', + name: 'encapsulation', + failureMessage: '.src/utils.ts cannot be imported. It is encapsulated.', + }); + suite.addTestCase({ + modulePath: 'src/app/shared/config/configuration.ts', + name: 'dependency-rule', + failureMessage: + 'module src/app/shared/config cannot access src/app/bookings. Tag shared has no clearance for tags domain:bookings,type:feature', + fromTag: 'shared', + toTags: 'domain:bookings,type:feature', + fromModulePath: 'src/app/shared/config/configuration.ts', + toModulePath: 'src/app/bookings', + }); + + const expectedResult = ` + + + + + + + + + +`; + + expect(builder.getReport()).toEqual(expectedResult); + }); +}); diff --git a/packages/core/src/lib/cli/tests/junit-reporter.spec.ts b/packages/core/src/lib/cli/tests/junit-reporter.spec.ts new file mode 100644 index 00000000..5e6c1bc6 --- /dev/null +++ b/packages/core/src/lib/cli/tests/junit-reporter.spec.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, it, expect, beforeAll } from 'vitest'; +import { VirtualFs } from '../../fs/virtual-fs'; +import getFs, { useVirtualFs } from '../../fs/getFs'; +import { toFsPath } from '../../file-info/fs-path'; +import { ProjectViolation } from '../project-violation'; +import { JunitReporter } from '../internal/reporter/junit/junit-reporter'; + +describe('JUnit reporter', () => { + let fs: VirtualFs; + beforeAll(() => { + useVirtualFs(); + fs = getFs() as VirtualFs; + }); + + beforeEach(() => { + fs.reset(); + fs.createDir('/project/customers/feature'); + fs.createDir('/project/shared/master-data'); + fs.createDir('/project/customers/ui'); + fs.createDir('/project/app/shared/form'); + }); + it('should create a xml-file in /.sheriff/project/violations.xml', () => { + const reporter = new JunitReporter({ + outputDir: '.sheriff', + projectName: 'project', + }); + const violations: ProjectViolation = { + totalEncapsulationViolations: 0, + totalViolatedFiles: 0, + totalDependencyRuleViolations: 2, + hasError: true, + violations: [ + { + filePath: '/project/customers/feature/feature.ts', + encapsulations: [], + dependencyRules: [], + dependencyRuleViolations: [ + { + rawImport: '@eternal/shared/master-data', + fromModulePath: toFsPath('/project/customers/feature'), + toModulePath: toFsPath('/project/shared/master-data'), + fromTag: 'domain:customers', + toTags: ['shared:master-data'], + }, + ], + }, + { + filePath: '/project/customers/ui/ui.ts', + encapsulations: [], + dependencyRules: [], + dependencyRuleViolations: [ + { + rawImport: '@eternal/shared/form', + fromModulePath: toFsPath('/project/customers/ui'), + toModulePath: toFsPath('/project/app/shared/form'), + fromTag: 'domain:customers', + toTags: ['shared:form'], + }, + ], + }, + ], + }; + reporter.createReport(violations); + + expect(fs.readFile('.sheriff/project/violations.xml')).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/lib/cli/tests/reporter-factory.spec.ts b/packages/core/src/lib/cli/tests/reporter-factory.spec.ts new file mode 100644 index 00000000..71792965 --- /dev/null +++ b/packages/core/src/lib/cli/tests/reporter-factory.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { reporterFactory } from '../internal/reporter/reporter-factory'; +import { JsonReporter } from '../internal/reporter/json/json-reporter'; +import { JunitReporter } from '../internal/reporter/junit/junit-reporter'; + +describe('ReporterFactory', () => { + const defaultOptions = { + outputDir: 'test-reports', + projectName: 'test-project', + }; + + it('should return empty array when no reporter formats are provided', () => { + const reporters = reporterFactory({ + ...defaultOptions, + reporterFormats: [], + }); + + expect(reporters).toEqual([]); + }); + + it('should create JsonReporter when json format is specified', () => { + const reporters = reporterFactory({ + ...defaultOptions, + reporterFormats: ['json'], + }); + + expect(reporters).toHaveLength(1); + expect(reporters[0]).toBeInstanceOf(JsonReporter); + }); + + it('should create JunitReporter when junit format is specified', () => { + const reporters = reporterFactory({ + ...defaultOptions, + reporterFormats: ['junit'], + }); + + expect(reporters).toHaveLength(1); + expect(reporters[0]).toBeInstanceOf(JunitReporter); + }); + + it('should create multiple reporters when multiple formats are specified', () => { + const reporters = reporterFactory({ + ...defaultOptions, + reporterFormats: ['json', 'junit'], + }); + + expect(reporters).toHaveLength(2); + expect(reporters[0]).toBeInstanceOf(JsonReporter); + expect(reporters[1]).toBeInstanceOf(JunitReporter); + }); + + it('should ignore unknown reporter formats', () => { + const reporters = reporterFactory({ + ...defaultOptions, + reporterFormats: ['json', 'unknown-format', 'junit'], + }); + + expect(reporters).toHaveLength(2); + expect(reporters[0]).toBeInstanceOf(JsonReporter); + expect(reporters[1]).toBeInstanceOf(JunitReporter); + }); + + it('should handle duplicate reporter formats by creating multiple instances', () => { + const reporters = reporterFactory({ + ...defaultOptions, + reporterFormats: ['json', 'json'], + }); + + expect(reporters).toHaveLength(2); + expect(reporters[0]).toBeInstanceOf(JsonReporter); + expect(reporters[1]).toBeInstanceOf(JsonReporter); + }); + + it('should maintain order of reporters based on format array order', () => { + const reporters1 = reporterFactory({ + ...defaultOptions, + reporterFormats: ['json', 'junit'], + }); + + const reporters2 = reporterFactory({ + ...defaultOptions, + reporterFormats: ['junit', 'json'], + }); + + expect(reporters1[0]).toBeInstanceOf(JsonReporter); + expect(reporters1[1]).toBeInstanceOf(JunitReporter); + + expect(reporters2[0]).toBeInstanceOf(JunitReporter); + expect(reporters2[1]).toBeInstanceOf(JsonReporter); + }); +}); diff --git a/packages/core/src/lib/cli/tests/verify.spec.ts b/packages/core/src/lib/cli/tests/verify.spec.ts index f8fde7fd..ad27a15f 100644 --- a/packages/core/src/lib/cli/tests/verify.spec.ts +++ b/packages/core/src/lib/cli/tests/verify.spec.ts @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, vitest, it } from 'vitest'; +import { beforeEach, describe, expect, vitest, it, vi } from 'vitest'; import { createProject } from '../../test/project-creator'; import { tsConfig } from '../../test/fixtures/ts-config'; import { main } from '../main'; import { sheriffConfig } from '../../test/project-configurator'; import { verifyCliWrappers } from './verify-cli-wrapper'; import { mockCli } from './helpers/mock-cli'; +import { JsonReporter } from '../internal/reporter/json/json-reporter'; +import { JunitReporter } from '../internal/reporter/junit/junit-reporter'; describe('verify', () => { beforeEach(() => { @@ -198,4 +200,147 @@ describe('verify', () => { expect(allLogs()).toMatchSnapshot('logs.log'); }); }); + + describe('Reporter integration', () => { + it('should create reports for single project workspace', () => { + mockCli(); + + // Spy on reporter createReport methods + const jsonReporterSpy = vi.spyOn(JsonReporter.prototype, 'createReport'); + const junitReporterSpy = vi.spyOn( + JunitReporter.prototype, + 'createReport', + ); + + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + reporters: ['json', 'junit'], + modules: { + 'src/customers': ['customers'], + 'src/holidays': ['holidays'], + }, + depRules: { + root: ['customers', 'holidays'], + customers: [], + holidays: [], + }, + }), + src: { + 'main.ts': ['./holidays', './customers'], + holidays: { + 'index.ts': ['./holidays.component'], + 'holidays.component.ts': ['../customers'], // violation + }, + customers: { 'index.ts': [] }, + }, + }); + + main('verify', 'src/main.ts'); + + expect(jsonReporterSpy).toHaveBeenCalledTimes(1); + expect(junitReporterSpy).toHaveBeenCalledTimes(1); + }); + + it('should create reports for multi-project workspace', () => { + mockCli(); + + const jsonReporterSpy = vi.spyOn(JsonReporter.prototype, 'createReport'); + const junitReporterSpy = vi.spyOn( + JunitReporter.prototype, + 'createReport', + ); + + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + entryPoints: { + 'project-i': 'projects/project-i/src/main.ts', + 'project-ii': 'projects/project-ii/src/main.ts', + }, + depRules: {}, + reporters: ['json', 'junit'], + }), + projects: { + 'project-i': { + src: { + 'main.ts': [], + 'app.ts': [], + }, + }, + 'project-ii': { + src: { + 'main.ts': [], + 'app.ts': [], + }, + }, + }, + }); + + main('verify', 'project-i,project-ii'); + + expect(jsonReporterSpy).toHaveBeenCalledTimes(2); + expect(junitReporterSpy).toHaveBeenCalledTimes(2); + }); + + it('should not create reports when no reporters are configured', () => { + mockCli(); + + const jsonReporterSpy = vi.spyOn(JsonReporter.prototype, 'createReport'); + const junitReporterSpy = vi.spyOn( + JunitReporter.prototype, + 'createReport', + ); + + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + modules: { + 'src/customers': ['customers'], + }, + depRules: { + root: ['customers'], + customers: [], + }, + }), + src: { + 'main.ts': ['./customers'], + customers: { 'index.ts': [] }, + }, + }); + + main('verify', 'src/main.ts'); + + expect(jsonReporterSpy).not.toHaveBeenCalled(); + expect(junitReporterSpy).not.toHaveBeenCalled(); + }); + + it('should create reports even when no violations found', () => { + mockCli(); + + const jsonReporterSpy = vi.spyOn(JsonReporter.prototype, 'createReport'); + + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + reporters: ['json'], + modules: { + 'src/customers': ['customers'], + }, + depRules: { + root: ['customers'], + customers: [], + }, + }), + src: { + 'main.ts': ['./customers'], + customers: { 'index.ts': [] }, + }, + }); + + main('verify', 'src/main.ts'); + + expect(jsonReporterSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/core/src/lib/cli/verify.ts b/packages/core/src/lib/cli/verify.ts index 59ce9fa1..1957bac2 100644 --- a/packages/core/src/lib/cli/verify.ts +++ b/packages/core/src/lib/cli/verify.ts @@ -11,20 +11,24 @@ import { getEntriesFromCliOrConfig, } from './internal/get-entries-from-cli-or-config'; import { logInfoForMissingSheriffConfig } from './internal/log-info-for-missing-sheriff-config'; +import { reporterFactory } from './internal/reporter/reporter-factory'; +import { ProjectViolation } from './project-violation'; +import { ProjectInfo } from '../main/init'; +import { Entry } from './internal/entry'; -type ValidationsMap = Record< - string, - { encapsulations: string[]; dependencyRules: string[] } ->; +export type Violation = { + filePath: string; + encapsulations: string[]; + dependencyRules: string[]; + dependencyRuleViolations: DependencyRuleViolation[]; +}; type ProjectValidation = { - deepImportsCount: number; dependencyRulesCount: number; + encapsulationsCount: number; filesCount: number; hasError: boolean; - validationsMap: ValidationsMap; - encapsulations: string[]; - dependencyRuleViolations: DependencyRuleViolation[]; + ruleViolations: Violation[]; }; export function verify(args: string[]) { @@ -43,13 +47,11 @@ export function verify(args: string[]) { // Initialize validation data for this project const validation: ProjectValidation = { - deepImportsCount: 0, dependencyRulesCount: 0, + encapsulationsCount: 0, filesCount: 0, hasError: false, - validationsMap: {}, - encapsulations: [], - dependencyRuleViolations: [], + ruleViolations: [], }; projectValidations.set(projectName, validation); @@ -64,16 +66,14 @@ export function verify(args: string[]) { projectEntry.entry, ); const projectValidation = projectValidations.get(projectName)!; - projectValidation.encapsulations = encapsulations; - projectValidation.dependencyRuleViolations = dependencyRuleViolations; if (encapsulations.length > 0 || dependencyRuleViolations.length > 0) { projectValidation.hasError = true; projectValidation.filesCount++; - projectValidation.deepImportsCount += encapsulations.length; projectValidation.dependencyRulesCount += dependencyRuleViolations.length; hasAnyProjectError = true; + projectValidation.encapsulationsCount += encapsulations.length; const dependencyRules = dependencyRuleViolations.map( (violation) => @@ -81,10 +81,12 @@ export function verify(args: string[]) { ); const relativePath = fs.relativeTo(fs.cwd(), fileInfo.path); - projectValidation.validationsMap[relativePath] = { + projectValidation.ruleViolations.push({ + filePath: relativePath, encapsulations, dependencyRules, - }; + dependencyRuleViolations, + }); } } } @@ -104,7 +106,7 @@ export function verify(args: string[]) { cli.log('Issues found:'); cli.log(` Total Invalid Files: ${validation.filesCount}`); cli.log( - ` Total Encapsulation Violations: ${validation.deepImportsCount}`, + ` Total Encapsulation Violations: ${validation.encapsulationsCount}`, ); cli.log( ` Total Dependency Rule Violations: ${validation.dependencyRulesCount}`, @@ -113,10 +115,12 @@ export function verify(args: string[]) { cli.log(''); // Display detailed validation information for this project - for (const [file, { encapsulations, dependencyRules }] of Object.entries( - validation.validationsMap, - )) { - cli.log('|-- ' + file); + for (const { + encapsulations, + dependencyRules, + filePath, + } of validation.ruleViolations) { + cli.log('|-- ' + filePath); if (encapsulations.length > 0) { cli.log('| |-- Encapsulation Violations'); encapsulations.forEach((encapsulation) => { @@ -139,6 +143,8 @@ export function verify(args: string[]) { } } + createReports(args, projectEntries, projectValidations); + // End process based on overall status if (hasAnyProjectError) { cli.endProcessError(); @@ -148,3 +154,42 @@ export function verify(args: string[]) { cli.endProcessOk(); } } + +function createReports( + args: string[], + projectEntries: Entry[], + projectValidations: Map, +) { + // Read reporters from the CLI + const reporterFormats = projectEntries[0].entry.config.reporters || []; + + if (reporterFormats.length > 0) { + for (const projectEntry of projectEntries) { + const projectName = projectEntry.projectName; + const projectValidation = projectValidations.get(projectName); + + const reportsDirectory = + projectEntry.entry.config.reportsDirectory || 'reports'; + + const reporters = reporterFactory({ + reporterFormats: reporterFormats, + outputDir: reportsDirectory, + projectName, + }); + + if (projectValidation) { + const violations: ProjectViolation = { + hasError: projectValidation.hasError, + totalDependencyRuleViolations: projectValidation.dependencyRulesCount, + totalEncapsulationViolations: projectValidation.encapsulationsCount, + totalViolatedFiles: projectValidation.filesCount, + violations: projectValidation.ruleViolations, + }; + + reporters.forEach((reporter) => { + reporter.createReport(violations); + }); + } + } + } +} diff --git a/packages/core/src/lib/config/default-config.ts b/packages/core/src/lib/config/default-config.ts index edb3c191..eaab6ace 100644 --- a/packages/core/src/lib/config/default-config.ts +++ b/packages/core/src/lib/config/default-config.ts @@ -13,4 +13,6 @@ export const defaultConfig: Configuration = { isConfigFileMissing: false, barrelFileName: 'index.ts', entryPoints: undefined, + reportsDirectory: '.sheriff/reports', + reporters: [], }; diff --git a/packages/core/src/lib/config/tests/parse-config.spec.ts b/packages/core/src/lib/config/tests/parse-config.spec.ts index 6c11f740..c59047e4 100644 --- a/packages/core/src/lib/config/tests/parse-config.spec.ts +++ b/packages/core/src/lib/config/tests/parse-config.spec.ts @@ -40,6 +40,8 @@ describe('parse Config', () => { 'isConfigFileMissing', 'barrelFileName', 'entryPoints', + 'reportsDirectory', + 'reporters', ]); }); @@ -81,6 +83,8 @@ export const config: SheriffConfig = { isConfigFileMissing: false, entryFile: '', barrelFileName: 'index.ts', + reporters: [], + reportsDirectory: '.sheriff/reports', }); }); diff --git a/packages/core/src/lib/config/user-sheriff-config.ts b/packages/core/src/lib/config/user-sheriff-config.ts index 1764fd84..7ce4adad 100644 --- a/packages/core/src/lib/config/user-sheriff-config.ts +++ b/packages/core/src/lib/config/user-sheriff-config.ts @@ -1,5 +1,6 @@ import { ModuleConfig } from './module-config'; import { DependencyRulesConfig } from './dependency-rules-config'; +import { ReporterFormat } from '../cli/internal/reporter/supported-reporter-formats'; /** * Exported by **sheriff.config.ts**. It is optional and should be located @@ -260,4 +261,14 @@ export interface UserSheriffConfig { * Either `entryFile` or `entryPoints` can be set, but not both. */ entryPoints?: Record; + /** + * The directory where the Sheriff CLI will write reports to. + * + * Default is `./sheriff/reports` + */ + reportsDirectory?: string; + /** + * The reporters used to generate reports. + */ + reporters?: ReporterFormat[]; }