Skip to content

Creating Custom Plugins

This guide shows how to create a custom codepol plugin. Codepol now supports two plugin transports:

  1. In-process JavaScript/TypeScript rule packages for advanced hosts and built-ins.
  2. Universal subprocess plugins for cross-language authoring.

The examples below still use the in-process rule authoring API because it is the most concise way to explain rule structure. When you wire a plugin into codepol.toml, prefer the subprocess transport for maximum portability.

Overview

A codepol plugin has two parts:

  1. TreeCheckProvider - The check logic (runs via CLI with Tree-sitter)
  2. ESLint rule - Generated automatically using the adapter

The recommended approach uses eslintAdapter to convert your TreeCheckProvider into an ESLint rule, so you write the check logic once.

Quick Start

1. Create the Package

bash
mkdir your-plugin
cd your-plugin
pnpm init
pnpm add -D @codepol/core @codepol/plugin-eslint typescript
bash
mkdir your-plugin
cd your-plugin
npm init -y
npm install -D @codepol/core @codepol/plugin-eslint typescript
bash
mkdir your-plugin
cd your-plugin
yarn init -y
yarn add -D @codepol/core @codepol/plugin-eslint typescript
bash
mkdir your-plugin
cd your-plugin
bun init
bun add -D @codepol/core @codepol/plugin-eslint typescript

2. Project Structure

Organize your plugin with clear separation of concerns:

your-plugin/
├── src/
│   ├── index.ts              # Entry point: exports plugin rules
│   └── rules/
│       ├── noDuplicateExportsCheck.ts    # TreeCheckProvider check logic
│       └── noDuplicateExportsRule.ts     # Rule definition + ESLint config
├── package.json
└── tsconfig.json

3. Write the Check Logic

Create src/rules/noDuplicateExportsCheck.ts with the pure check function:

typescript
import type {
  PolicyRule,
  PolicyCheckContext,
  PolicyViolation,
} from '@codepol/core';

export function noDuplicateExportsCheck(
  rule: PolicyRule,
  context: PolicyCheckContext
): PolicyViolation[] {
  const violations: PolicyViolation[] = [];
  const lines = context.source.split('\n');
  const exportedNames = new Map<string, number>();

  // Resolved args from codepol.toml are available in context.ruleArgs
  // const args = context.ruleArgs;

  const exportRegex = /export\s+(?:const|let|var|function|class|type|interface)\s+(\w+)/;

  lines.forEach((line, index) => {
    const match = line.match(exportRegex);
    if (match) {
      const name = match[1];
      if (exportedNames.has(name)) {
        violations.push({
          ruleId: rule.id || rule.ruleId,
          filePath: context.filePath,
          message: `Duplicate export '${name}' (first exported on line ${exportedNames.get(name)})`,
          line: index + 1,
          column: match.index! + 1,
        });
      } else {
        exportedNames.set(name, index + 1);
      }
    }
  });

  return violations;
}

4. Create the Rule Definition

Create src/rules/noDuplicateExportsRule.ts with the rule and ESLint configuration:

typescript
import type {
  CodepolPluginRule,
  LintProvider,
  EslintProviderConfig,
} from '@codepol/core';
import { pluginRuleNew, treeCheckProviderNew } from '@codepol/core';
import { eslintAdapter } from '@codepol/plugin-eslint';
import { noDuplicateExportsCheck } from './noDuplicateExportsCheck';

// Create the TreeCheckProvider using the factory
export const noDuplicateExportsTreeCheck = treeCheckProviderNew({
  languages: ['typescript', 'tsx'],
  check: noDuplicateExportsCheck,
});

// Rule ID must NOT contain '/' - codepol uses '/' for namespacing.
// Your ID will be auto-prefixed: "no-duplicate-exports" → "@scope/plugin/no-duplicate-exports"
const ruleId = 'no-duplicate-exports';

// Create rule plugin base for the adapter
const ruleBase = pluginRuleNew({
  id: ruleId,
  capabilities: { treeCheckProvider: noDuplicateExportsTreeCheck },
});

// Generate ESLint rule from TreeCheckProvider
const eslintRule = eslintAdapter.adapt(ruleBase, {
  ruleName: 'no-duplicate-exports',
});

const eslintConfig: EslintProviderConfig = {
  rules: { 'no-duplicate-exports': eslintRule },
  // severity and ruleOptions are optional
};

const lintProvider: LintProvider = {
  platform: 'eslint',
  languages: ['typescript', 'tsx'],
  config: eslintConfig,
};

// Export the complete rule plugin
export const noDuplicateExportsRule: CodepolPluginRule = pluginRuleNew({
  id: ruleId,
  capabilities: {
    treeCheckProvider: noDuplicateExportsTreeCheck,
    lintProviders: [lintProvider],
  },
});

Biome provider example

Use a Biome provider when your rule should delegate JS/TS enforcement to the Biome CLI instead of ESLint. This is a host hook: codepol shells out to biome lint and merges RDJSON diagnostics. It does not embed Codepol logic as native Biome rules (no custom Biome plugin API).

typescript
const biomeProvider: LintProvider = {
  platform: 'biome',
  languages: ['typescript', 'tsx', 'javascript', 'jsx'],
  config: {
    biomeBin: 'biome',
    configPath: './biome.json',
  },
};

export const biomeBackedRule = pluginRuleNew({
  id: 'biome-backed-rule',
  capabilities: {
    lintProviders: [biomeProvider],
  },
});

When users run codepol, the CLI runs biome lint only for files matched by this rule’s policy targets that enable the Biome provider. When they run codepol --fix, the CLI runs biome lint --write for those files. If two policy rules need different Biome invocations (different configPath / extraArgs / binary), use different provider configs; each distinct config is executed once per run. Do not attach two different Biome provider configs to the same rule plugin — that is rejected as a conflict.

Policy severity and ruleArgs are not forwarded into Biome the way EslintProviderConfig.ruleOptions forwards into ESLint; configure Biome via biome.json and the provider config instead.

Understanding ruleOptions

ruleOptions is an optional field on EslintProviderConfig.

FieldTypeDefaultDescription
ruleOptions(ctx: LintProviderContext) => unknown{}Options to pass to the ESLint rule

When is ruleOptions needed?

  • Simple rules: Omit entirely — defaults to {}
  • Rules using eslintAdapter: Pass policy context so the adapted rule can filter files
  • Rules with custom arguments: Forward ctx.ruleArgs from config

Common options for eslintAdapter rules:

OptionTypeDescription
configPathstringPath to the config file
ruleTargetsPolicyRuleTargetContext[]Resolved rule targets from the policy
policyExcludestring[]Global exclude patterns from the policy

Example (with ruleOptions):

typescript
const eslintConfig: EslintProviderConfig = {
  rules: { 'my-rule': eslintRule },
  ruleOptions: (ctx: LintProviderContext) => ({
    configPath: ctx.configPath,  // Pass config path to the ESLint rule
    ruleTargets: ctx.ruleTargets,
    policyExclude: ctx.policy.exclude,
    ...(ctx.ruleArgs as Record<string, unknown>),
  }),
};

Example (simple rule, defaults only):

typescript
const eslintConfig: EslintProviderConfig = {
  rules: { 'simple-rule': eslintRule },
  // ruleOptions defaults to {}
};

Severity in config

Severity is controlled by users in codepol.toml, not by plugins. This allows teams to customize enforcement per rule. See Configuring Severity.

5. Create the Entry Point

Create src/index.ts as the clean entry point:

typescript
export { noDuplicateExportsRule } from './rules/noDuplicateExportsRule';

// Default export is required for codepol to load the plugin
export default [noDuplicateExportsRule];

6. Configure codepol.toml

Create codepol.toml in your project root. This file declares which plugins to load and how rules apply to your codebase.

For the complete schema reference, see Policy Schema Reference.

Minimal example:

toml
[[plugins]]
id = "@your-org/codepol-plugin-foo"

[plugins.source]
kind = "process"
command = "python3"
args = ["./tools/codepol_plugin.py"]

[targets.typescript]
language = "typescript"
files = ["src/**/*.ts"]

[[rules]]
ruleId = "@your-org/codepol-plugin-foo/no-duplicate-exports"
description = "Disallow duplicate exports"
severity = "warn"
targets = ["typescript"]

Configuring Severity

The severity field controls how violations are reported:

ValueDescription
'error'(default) Violations cause CLI exit code 1
'warn'Violations are reported but don't fail the build
'off'Rule is disabled

Manual ESLint Configuration

Users can also configure rules directly in their eslint.config.js instead of using codepol.toml:

javascript
rules: {
  'codepol/require-logger-enter-exit': ['error', { /* options */ }],
}

This works when running ESLint directly. However, when using codepol check, severity from codepol.toml takes precedence via ESLint's overrideConfig.

Filtering Providers

The providers field controls which providers a rule applies to. If omitted, the rule applies to all providers.

json
{
  "ruleId": "@codepol/plugin/require-logger-enter-exit",
  "providers": ["tree-sitter"],
  "targets": ["typescript"]
}
ValueDescription
undefined or [](default) Rule applies to all providers
['eslint']Rule only applies to ESLint
['tree-sitter']Rule only applies to tree-sitter checks
['eslint', 'tree-sitter']Rule applies to both

Codepol automatically resolves the ruleId by combining the module name and the exported rule ID: your-plugin/no-duplicate-exports.

7. Test It

bash
pnpm codepol
bash
npx codepol
bash
yarn codepol
bash
bunx codepol

The CLI auto-discovers codepol.toml from your project root. You can also specify a custom config path:

bash
npx codepol --config ./config/codepol.toml

Exporting Your Plugin

Plugins must use a default export containing an array of CodepolPluginRule objects. This is the only export convention codepol uses—consumers never need to specify an export name.

Important: All rule plugins must be created using pluginRuleNew(). This validates the rule ID at construction time and ensures type safety. Direct object literals won't type-check.

typescript
// src/index.ts
import { pluginRuleNew, type CodepolPluginRule } from '@codepol/core';

// ✓ Correct - uses pluginRuleNew()
export const myRule: CodepolPluginRule = pluginRuleNew({
  id: 'my-rule',  // Must NOT contain '/'
  capabilities: { /* ... */ },
});

export const anotherRule: CodepolPluginRule = pluginRuleNew({
  id: 'another-rule',
  capabilities: { /* ... */ },
});

// Default export is required for codepol to load the plugin
export default [myRule, anotherRule];

Rule ID Constraint

Rule IDs must not contain /. The / character is reserved for namespacing—codepol automatically prefixes your ID with the module name (e.g., my-rule@your-org/plugin/my-rule).

Consumers reference your plugin by transport-neutral plugin id and source declaration:

json
{
  "id": "@your-org/codepol-plugin-foo",
  "source": {
    "kind": "process",
    "command": "python3",
    "args": ["./tools/codepol_plugin.py"]
  }
}

Multiple Plugin Collections

For packages with multiple distinct plugin collections, use Node.js subpath exports in your package.json:

json
{
  "exports": {
    ".": "./dist/index.js",
    "./security": "./dist/security.js",
    "./logging": "./dist/logging.js"
  }
}

Each subpath module should have its own default export:

typescript
// src/security.ts
export default [xssRule, csrfRule];

// src/logging.ts
export default [loggerRule, auditRule];

Consumers reference specific collections via the subpath:

json
{ "module": "@your-org/plugin/security" }
{ "module": "@your-org/plugin/logging" }

Linter Integration

ESLint Integration

There are two approaches to integrate your plugin with ESLint:

For simple rules without autofix, use the adapter to automatically convert your TreeCheckProvider:

typescript
import { eslintAdapter } from '@codepol/plugin-eslint';

const eslintRule = eslintAdapter.adapt(rulePlugin, {
  ruleName: 'no-duplicate-exports',
  ruleUrl: 'https://your-docs/rules/no-duplicate-exports',
  severity: 'error', // or 'warning'
});

The adapter handles:

  • Policy file loading and caching
  • File matching against rule targets
  • Converting violations to ESLint diagnostics

B. Manual ESLint Rule (For Autofix)

For rules that need autofix support or complex ESLint schemas, write the ESLint rule manually. See the built-in logger rule at packages/plugin/src/index.ts for a complete example with autofix.

Key steps for manual rules:

  1. Use ESLintUtils.RuleCreator from @typescript-eslint/utils
  2. Define your schema in the rule's meta.schema
  3. Implement the create function with AST visitors
  4. Add fix or suggest functions for autofix support

Configure ESLint

Add to your eslint.config.js:

javascript
import { eslintPluginCreate } from '@codepol/plugin-eslint';
import pluginRules from '@codepol/plugin';       // Built-in rules
import customRules from './your-plugin';         // Your custom rules

export default [
  {
    plugins: {
      codepol: eslintPluginCreate([...pluginRules, ...customRules]),
    },
    rules: {
      'codepol/require-logger-enter-exit': 'error',
      'codepol/no-duplicate-exports': 'error',
    },
  },
];

esbuild Integration

Enforce policies at build time with the esbuild plugin:

typescript
import { build } from 'esbuild';
import { esbuildPluginCreate } from '@codepol/plugin-esbuild';

await build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/bundle.js',
  plugins: [
    // Auto-discovers codepol.toml
    esbuildPluginCreate({
      fix: false, // Set to true to auto-fix violations
    }),
  ],
});

The esbuild plugin runs both ESLint checks and Tree-sitter analysis, failing the build if violations are found.

Other Build Tools

Codepol's architecture supports future adapters via the LintProvider interface. The same pattern can be implemented for:

  • Biome - Fast Rust-based linter
  • Ruff - Python linter (for Python codepol plugins)
  • Vite - Via plugin wrapping esbuild

To create an adapter for another platform, implement the TreeCheckLintAdapter interface from @codepol/core.

Advanced Topics

Accessing Rule Arguments

Rule-specific arguments from codepol.toml are passed via context.ruleArgs:

typescript
function myCheck(rule: PolicyRule, context: PolicyCheckContext): PolicyViolation[] {
  const args = context.ruleArgs as { threshold?: number };
  const threshold = args?.threshold ?? 10;
  // ... use threshold in your check
}

Tree-sitter for AST Analysis

For structural code analysis, use Tree-sitter instead of string matching:

typescript
import { parserGetForFile, isErr } from '@codepol/core';

function astCheck(rule: PolicyRule, context: PolicyCheckContext): PolicyViolation[] {
  const parserResult = parserGetForFile(context.filePath);
  if (isErr(parserResult)) {
    throw new Error(parserResult.Err);  // treeCheckProviderNew catches and wraps
  }
  
  const parser = parserResult.Ok;
  const tree = parser.parse(context.source);
  
  // Traverse tree.rootNode to analyze AST
  // ...
}

See packages/plugin/src/policyPluginLogger.ts for a complete Tree-sitter example.

Adding Fix Capabilities

Plugins can provide automated fixes via the fixProvider capability. Fix providers run when users pass the --fix flag to the CLI.

FixProvider Interface

typescript
type FixProvider = {
  apply: (context: FixProviderContext) => void | Promise<void>;
};

type FixProviderContext = {
  cwd: string;                              // Current working directory
  policy: PolicyFile;                       // Loaded policy definition
  configPath: string;                       // Path to config file
  files: string[];                          // Files matched by policy rules
  ruleTargets?: PolicyRuleTargetContext[];  // Rule targets with args
};

Example Implementation

typescript
import { pluginRuleNew, type FixProvider, type FixProviderContext } from '@codepol/core';
import fs from 'fs';

const myFixProvider: FixProvider = {
  apply: async (context: FixProviderContext) => {
    for (const filePath of context.files) {
      const source = fs.readFileSync(filePath, 'utf8');
      // Apply your fix transformation
      const fixed = source.replace(/TODO/g, 'DONE');
      fs.writeFileSync(filePath, fixed);
    }
  },
};

export const myRule = pluginRuleNew({
  id: 'my-fixer-rule',
  capabilities: {
    treeCheckProvider: myTreeCheck,
    fixProvider: myFixProvider,
  },
});

export default [myRule];

Execution Order

Fix providers run before linting, so the linted output reflects the fixed state. To trigger fixes:

bash
codepol --fix

TIP

For ESLint-based autofix (inline fixes with AST context), write a manual ESLint rule with fix or suggest functions instead. See the Manual ESLint Rule section.