Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a07a99d
refactor(core): add reporter options to config
michaelbe812 Jul 26, 2025
403569f
refactor(core): add util function to parse reporters from cli
michaelbe812 Jul 26, 2025
1a0e31f
refactor(core): add JSON-reporter
michaelbe812 Jul 26, 2025
98cab7e
refactor(core): rename reportersDirectory to reportsDirectory
michaelbe812 Jul 26, 2025
976cf66
feat(core): add JSON Reporter
michaelbe812 Jul 26, 2025
7f7f21c
feat(core): create reports
michaelbe812 Jul 26, 2025
f0d89ff
refactor(core): simplify validationsmap
michaelbe812 Jul 26, 2025
8e0da2a
refactor(core): simplify creating reports
michaelbe812 Jul 26, 2025
586bda3
refactor(core): simplify creating reports
michaelbe812 Jul 26, 2025
bf0be53
refactor(core): rename
michaelbe812 Jul 27, 2025
c36e909
fix(core): remove wrong assignment of deepImportsCount in verify
michaelbe812 Jul 27, 2025
2117e14
feat(core): wip junit report builder
michaelbe812 Jul 27, 2025
60d8e3e
feat(core): add JUnitReporter
michaelbe812 Jul 27, 2025
784da74
refactor(core): move json reporter
michaelbe812 Jul 27, 2025
0398afa
refactor(core): add comment
michaelbe812 Jul 27, 2025
40dc7bc
fix(core): do not create /default directoy for single workspace projects
michaelbe812 Jul 27, 2025
48750f2
refactor(core): rename
michaelbe812 Jul 27, 2025
baa853f
refactor(core): move files
michaelbe812 Jul 27, 2025
7db35c7
fix(core): remove cli flags starting with -- from args
michaelbe812 Jul 27, 2025
9c27193
refactor(core): drop possibility to provide reporters via cli
michaelbe812 Jul 27, 2025
9057586
refactor(core): rename defaultReporter to reporters in config
michaelbe812 Jul 27, 2025
3208561
chore(core): update tests
michaelbe812 Jul 27, 2025
8c0914d
chore(core): add tests
michaelbe812 Jul 27, 2025
00012e6
feat(core): add ReporterFormat type
michaelbe812 Jul 27, 2025
89c4177
refactor(core): rename SheriffViolations to ProjectViolation
michaelbe812 Jul 27, 2025
9ad6e10
chore(core): add tests
michaelbe812 Jul 27, 2025
a6ab0ea
refactor(core): remove console..og
michaelbe812 Jul 27, 2025
30e5f78
chore(core): add tests
michaelbe812 Jul 27, 2025
8c21b1d
docs(core): document Reports
michaelbe812 Jul 27, 2025
9744aab
refactor(core): centralize shared logic for reporter
michaelbe812 Jul 28, 2025
31042f8
fix(docs): fix typo
michaelbe812 Jul 28, 2025
5623c3b
fix(core): do not push deep-import issue when encapsulation issue is …
michaelbe812 Jul 28, 2025
413ad1c
refactor(core): add reporter registry
michaelbe812 Jul 28, 2025
9215582
fix(core): spacing issue when creating JUnit report
michaelbe812 Jul 28, 2025
57de38c
refactor(core): rename property validationsMap to ruleViolations
michaelbe812 Jul 28, 2025
e9f23cd
fix(core): remove uneccessary whitespace in XML-report
michaelbe812 Jul 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/docs/reports.md
Original file line number Diff line number Diff line change
@@ -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`.
20 changes: 20 additions & 0 deletions packages/core/src/lib/cli/internal/reporter/json/json-reporter.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
</testsuites>`;
}

let report = `<?xml version="1.0" encoding="UTF-8"?>
<testsuites>\n`;

for (const suite of this.testSuites) {
const testSuite = suite as JUnitTestSuite;

report += ` <testsuite name="${suite.name}" totalDependencyRulesViolations="${suite.totalDependencyRulesViolations}" totalEncapsulationViolations="${suite.totalEncapsulationViolations}" totalViolatedFiles="${suite.totalViolatedFiles}" hasError="${suite.hasError}">\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 += ` <testcase ${attributes.join(' ')}>\n`;
report += ` <failure message="${testCase.failureMessage}"/>\n`;
report += ` </testcase>\n`;
}

report += ` </testsuite>\n`;
}

report += `</testsuites>`;

return report;
}
}

export function junitBuilder(): JUnitBuilder {
return new JUnitReportBuilder();
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
39 changes: 39 additions & 0 deletions packages/core/src/lib/cli/internal/reporter/reporter-factory.ts
Original file line number Diff line number Diff line change
@@ -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<string, ReporterConstructor>([
['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());
}
5 changes: 5 additions & 0 deletions packages/core/src/lib/cli/internal/reporter/reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ProjectViolation } from '../../project-violation';

export interface Reporter {
createReport(validationResults: ProjectViolation): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getRegisteredFormats } from './reporter-factory';

export type ReporterFormat = 'json' | 'junit';

export const SUPPORTED_REPORTER_FORMATS = getRegisteredFormats();
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ReporterOptions {
outputDir: string;
projectName: string;
}
47 changes: 47 additions & 0 deletions packages/core/src/lib/cli/internal/reporter/utils/save-report.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
9 changes: 9 additions & 0 deletions packages/core/src/lib/cli/project-violation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Violation } from './verify';

export type ProjectViolation = {
totalDependencyRuleViolations: number;
totalEncapsulationViolations: number;
totalViolatedFiles: number;
hasError: boolean;
violations: Violation[];
};
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
]
}"
`;
Original file line number Diff line number Diff line change
@@ -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`] = `
"<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="project" totalDependencyRulesViolations="2" totalEncapsulationViolations="0" totalViolatedFiles="0" hasError="true">
<testcase modulePath="/project/customers/feature/feature.ts" name="dependency-rule" fromTag="domain:customers" toTags="shared:master-data" fromModulePath="/project/customers/feature" toModulePath="/project/shared/master-data">
<failure message="module /project/customers/feature cannot access /project/shared/master-data. Tag domain:customers has no clearance for tags shared:master-data"/>
</testcase>
<testcase modulePath="/project/customers/ui/ui.ts" name="dependency-rule" fromTag="domain:customers" toTags="shared:form" fromModulePath="/project/customers/ui" toModulePath="/project/app/shared/form">
<failure message="module /project/customers/ui cannot access /project/app/shared/form. Tag domain:customers has no clearance for tags shared:form"/>
</testcase>
</testsuite>
</testsuites>"
`;
Loading