Skip to content

RFC: Sheriff Plugin System #243

@rainerhahnekamp

Description

@rainerhahnekamp

Summary

This RFC proposes a plugin system for Sheriff that enables external developers to extend Sheriff's functionality through npm packages. Plugins will integrate seamlessly with Sheriff's CLI and have access to Sheriff's internal API.

Table of Contents

Motivation & Use Cases

Why a Plugin System?

Sheriff's codebase is very complicated, and it is not easy to add new features to the core. Adding features to Sheriff core requires a thorough review process and deep understanding of the internal architecture. Peripheral features that might not require the whole review process can be added as plugins instead. This allows the community to extend Sheriff's functionality without modifying the core codebase.

Currently, Sheriff provides a fixed set of CLI commands (verify, list, export, init, version) and a limited public API. While this works well for core functionality, there are use cases that would benefit from extensibility:

  1. Custom Output Formats: Developers may need Sheriff results in formats not natively supported (e.g., JUnit XML for CI/CD integration)
  2. Workflow Automation: Plugins could integrate Sheriff results into build pipelines, PR comments, or notification systems
  3. Third-Party Integrations: External tools could consume Sheriff's analysis for their own purposes

Example Use Cases

  • Sheriff UI: A UI which can visually display project graphs and allows user to interactively edit the configuration.
  • JUnit Integration: A plugin that formats Sheriff verification results as JUnit XML for CI/CD systems
  • Custom Reporters: Plugins that generate HTML reports, JSON exports with custom schemas, or Slack notifications
  • Build Tool Integration: Plugins that integrate with specific build systems or monorepo tools

Benefits

  • Ecosystem Growth: Enables community contributions without requiring changes to Sheriff core
  • Flexibility: Teams can customize Sheriff to fit their specific workflows
  • Separation of Concerns: Core Sheriff remains focused, while extensions live in separate packages
  • Easier Maintenance: Plugin authors can iterate independently without affecting Sheriff's release cycle

High-Level Architecture

Configuration Design

Plugins will be configured in sheriff.config.ts using an optional plugins property. The base type is Record<string, string>, mapping plugin keys to npm package names:

import { SheriffConfig } from '@softarc/sheriff-core';

export const config: SheriffConfig = {
  version: 1,
  modules: {
    'src/feature': 'feature',
    'src/shared': 'shared',
  },
  depRules: {
    feature: 'shared',
  },
  plugins: {
    junit: 'mberger-junit-sheriff',
    ui: '@softarc/sheriff-ui',
    customReporter: '@company/sheriff-reporter',
  },
};

Design Decisions:

  • The plugins property is optional - existing configs without plugins continue to work
  • Base type is Record<string, string> where keys are user-defined identifiers and values are npm package names
  • Plugin keys are user-defined identifiers (e.g., junit, customReporter)
  • Plugins can extend the configuration type via TypeScript module augmentation (see Plugin Configuration section below)

CLI Integration

Plugins will be invoked via the CLI using their configured key:

npx sheriff <plugin-key> [additional-args]

Examples:

  • npx sheriff junit - Runs the JUnit plugin
  • npx sheriff ui - Starts the Sheriff UI
  • npx sheriff customReporter --format=html - Runs custom reporter with options
  • npx sheriff verify - Still works as before (built-in command takes precedence)

Command Resolution:

  1. Check if command is a built-in Sheriff command (verify, list, export, init, version)
  2. If not found, check if command matches a plugin key in the config
  3. If found, load and execute the plugin
  4. If not found, show help message

Plugin Discovery & Loading

  1. Config Parsing: Sheriff reads sheriff.config.ts and extracts the optional plugins configuration
  2. Package Resolution: For the requested plugin key, Sheriff extracts the npm package name:
    • If the value is a string, use it directly as the package name
    • If the value is an object (extended configuration), extract the name property as the package name
  3. Dynamic Loading: Sheriff dynamically requires/imports the plugin package
  4. Plugin Execution: The plugin receives the Sheriff API and CLI arguments

Loading Strategy:

  • Plugins must be installed in the project's node_modules
  • Sheriff will attempt to load the plugin's main entry point
  • Plugins should export a default function

Plugin API Design

Sheriff API Surface

Plugins will receive access to the Sheriff API, which can be extended per use-case.

Plugin Contract

Plugins must export a default async function that Sheriff can call. The function signature:

type PluginFunction = (api: SheriffPluginAPI, args: string[]) => Promise<void>;

interface SheriffPluginAPI {
  verify(entryFile?: string): VerificationResult;
  getProjectData(entryFile?: string, options?: Options): ProjectData;
  getConfig(): SheriffConfig;
  log(message: string): void;
  logError(message: string): void;
}

Key Points:

  • Plugins are asynchronous - the API is structured as async for consistency
  • CLI arguments are passed as string[] - plugins are responsible for parsing their own arguments
  • Plugins receive the full Sheriff API and can access all available functionality
  • Plugins can access their own configuration via getConfig() which includes plugin-specific config

Example Plugin:

// mberger-junit-sheriff/src/index.ts
import type { PluginFunction, SheriffPluginAPI } from '@softarc/sheriff-core';

export default async function junitPlugin(
  api: SheriffPluginAPI,
  args: string[],
): Promise<void> {
  const config = await api.getConfig();
  const pluginConfig = config.plugins?.junit;

  // Access plugin-specific configuration (if extended type is used)
  // For simple string config, pluginConfig will be a string
  // For extended config, pluginConfig will be an object with name and other properties
  const junitVersion =
    typeof pluginConfig === 'object' && pluginConfig?.junitVersion
      ? pluginConfig.junitVersion
      : 1;
  const reporters =
    typeof pluginConfig === 'object' && pluginConfig?.reporters
      ? pluginConfig.reporters
      : ['xml'];

  const result = await api.verify();

  // Generate JUnit XML
  const xml = generateJUnitXML(result, { version: junitVersion });

  // Parse CLI arguments (plugin's responsibility)
  const outputPath = args[0] || 'sheriff-junit.xml';
  writeFileSync(outputPath, xml);

  api.log(`JUnit report written to ${outputPath}`);
} satisfies PluginFunction

Resolved Design Decisions

Plugin Versioning

Plugin versioning is managed via npm's peerDependencies. Plugins should declare their compatibility with Sheriff versions in their package.json:

{
  "name": "mberger-junit-sheriff",
  "peerDependencies": {
    "@softarc/sheriff-core": "^0.19.0"
  }
}

Sheriff will validate plugin compatibility by checking peer dependency ranges. This leverages npm's existing version resolution mechanism.

Plugin Arguments

Sheriff passes CLI arguments to plugins as a string[] array. Plugins are responsible for parsing their own arguments. This keeps Sheriff's API simple and gives plugins full flexibility in argument handling.

Plugin Lifecycle

No lifecycle hooks are planned at this time. Plugins are executed independently when invoked via the CLI. Future lifecycle events (e.g., beforeVerify, afterVerify) can be added if needed.

Plugin Communication

Plugins cannot communicate with each other directly. Each plugin execution is isolated. If inter-plugin communication is needed in the future, it can be added as a feature.

Plugin Permissions

Plugins have full access to the Sheriff API and can perform any operations available through it. There is no permission system restricting plugin capabilities. Users opt-in to plugins by installing and configuring them, accepting the security implications.

Async/Sync

The plugin API is structured as asynchronous. All plugin functions must be async (Promise<void>), and all Sheriff API methods return Promises. This ensures consistency and allows for future async operations.

Plugin Configuration

While the base plugins configuration is Record<string, string>, plugins can extend the SheriffConfig interface via TypeScript module augmentation to add type-safe plugin-specific configuration. This allows plugins to accept additional configuration options beyond just the package name.

TypeScript Interface Extension:

Plugins can extend the SheriffConfig interface to provide type-safe configuration. The base type is Record<string, string>, but plugins can extend it using intersection types:

// In mberger-junit-sheriff package
declare module '@softarc/sheriff-core' {
  interface SheriffConfig {
    plugins?: Record<string, string> & {
      junit?: {
        name: string;
        junitVersion?: number;
        reporters?: string[];
      };
      // ... other plugins can extend here
    };
  }
}

This allows the plugins property to be Record<string, string> while also allowing specific keys (like junit) to have extended object types. The configuration can then be used like:

export const config: SheriffConfig = {
  version: 1,
  modules: {
    /* ... */
  },
  depRules: {
    /* ... */
  },
  plugins: {
    ui: '@softarc/sheriff-ui', // Simple string (from Record<string, string>)
    junit: {
      // Extended type with plugin-specific config
      name: 'mberger-junit-sheriff',
      junitVersion: 1,
      reporters: ['html'],
    },
  },
};

Alternatives Considered

  1. Subcommands via npm scripts: Instead of npx sheriff <plugin>, use npx <plugin-package>. Rejected because it would be a wrapper around Sheriff (like Detective)

  2. Plugin registry: A centralized registry for plugins. Deferred to future consideration - initially, plugins will be discovered via npm.

  3. Built-in extensibility: Adding more built-in commands for common use cases. Rejected because it would bloat Sheriff core and limit flexibility.

Migration Path

  • Existing sheriff.config.ts files without plugins continue to work unchanged
  • No breaking changes to existing APIs
  • Plugins are opt-in - users must explicitly configure them
  • Built-in commands (verify, list, etc.) maintain backward compatibility

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions