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[];
}