Write oxlint custom lint rules with Effect v4.
effect-oxlint wraps @oxlint/plugins in Effect idioms so rule authors get typed errors, composable visitors, Option-safe AST matching, and Ref-based state without any mutable variables.
Rule.define— writecreateas an Effect generator; useyield*for state, context, and diagnosticsVisitor.*— composable visitor combinators:on,onExit,merge,tracked,filter,accumulateAST.*—Option-returning matchers with dual API (data-first and data-last):matchMember,matchCallOf,matchImport,narrow,memberPathDiagnostic.*— structured diagnostic builders with composable autofixesSourceCode.*/Scope.*— effectful queries over tokens, comments, scope, and variablesTesting.*— mock builders, rule runners, and assertion helpers for@effect/vitestPlugin.define— assemble rules into a plugin that oxlint can load
npm install effect-oxlint effect@4.0.0-beta.43bun add effect-oxlint effect@4.0.0-beta.43deno add jsr:@effect-oxlint/effect-oxlintimport * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { AST, Diagnostic, Rule, RuleContext } from 'effect-oxlint';
const noJsonParse = Rule.define({
name: 'no-json-parse',
meta: Rule.meta({
type: 'suggestion',
description: 'Use Schema for JSON decoding instead of JSON.parse'
}),
create: function* () {
const ctx = yield* RuleContext;
return {
// node is typed as ESTree.MemberExpression automatically
MemberExpression: (node) =>
Option.match(
AST.matchMember(node, 'JSON', ['parse', 'stringify']),
{
onNone: () => Effect.void,
onSome: (matched) =>
ctx.report(
Diagnostic.make({
node: matched,
message: 'Use Schema for JSON'
})
)
}
)
};
}
});import { Rule } from 'effect-oxlint';
// Ban a member expression
const noMathRandom = Rule.banMember('Math', 'random', {
message: 'Use the Effect Random service instead'
});
// Ban an import
const noNodeFs = Rule.banImport('node:fs', {
message: 'Use the Effect FileSystem service instead'
});
// Ban a statement type
const noThrow = Rule.banStatement('ThrowStatement', {
message: 'Use Effect.fail instead of throw'
});
// Ban bare identifier calls (e.g. fetch(), useState())
const noFetch = Rule.banCallOf('fetch', {
message: 'Use Effect HTTP client instead'
});
// Ban new expressions (e.g. new Date(), new Error())
const noNewDate = Rule.banNewExpr('Date', {
message: 'Use Clock service instead'
});
// Ban multiple patterns with one rule
const noImperativeLoops = Rule.banMultiple(
{
statements: [
'ForStatement',
'ForInStatement',
'ForOfStatement',
'WhileStatement',
'DoWhileStatement'
]
},
{ message: 'Use Arr.map / Effect.forEach instead' }
);
// Combine different ban types in a single rule
const useClockService = Rule.banMultiple(
{
newExprs: 'Date',
members: [['Date', 'now']]
},
{ message: 'Use Clock service' }
);import { Plugin } from 'effect-oxlint';
export default Plugin.define({
name: 'my-effect-rules',
rules: {
'no-json-parse': noJsonParse,
'no-math-random': noMathRandom,
'no-node-fs': noNodeFs,
'no-throw': noThrow
}
});Visitors are Record<string, (node) => Effect<void>> maps. The Visitor module provides combinators to build and compose them.
import { Visitor } from 'effect-oxlint';
const combined = Visitor.merge(importVisitor, memberVisitor, statementVisitor);When two visitors handle the same node type, both handlers run sequentially.
Replace mutable let depth = 0 counters with Visitor.tracked:
import * as Ref from 'effect/Ref';
import { AST, Visitor } from 'effect-oxlint';
const depthRef = yield * Ref.make(0);
const tracker = Visitor.tracked(
'CallExpression',
// node is typed as ESTree.CallExpression
(node) => AST.isCallOf(node, 'Effect', 'gen'),
depthRef
);
// depthRef increments on enter, decrements on exitCollect data during traversal, then analyze at Program:exit:
import { Visitor, AST } from 'effect-oxlint';
const visitor =
yield *
Visitor.accumulate(
'ExportNamedDeclaration',
(node) => AST.narrow(node, 'ExportNamedDeclaration'),
function* (exports) {
// all exports collected — analyze them here
}
);Restrict a visitor to specific files:
import { Visitor } from 'effect-oxlint';
const visitor =
yield *
Visitor.filter((filename) => !filename.endsWith('.test.ts'), mainVisitor);Every matcher returns Option for safe composition with pipe, Option.map, and Option.flatMap. All public matchers support dual API (data-first and data-last).
import { pipe } from 'effect';
import * as Option from 'effect/Option';
import type { ESTree } from 'effect-oxlint';
import { AST } from 'effect-oxlint';
// Data-first (pass a MemberExpression directly)
declare const memberNode: ESTree.MemberExpression;
AST.matchMember(memberNode, 'JSON', ['parse', 'stringify']);
// Data-last (pipe-friendly)
pipe(memberNode, AST.matchMember('Effect', 'gen'));
// Chain: narrow an ESTree.Node, then match
declare const node: ESTree.Node;
pipe(
AST.narrow(node, 'CallExpression'),
Option.flatMap(AST.matchCallOf('Effect', 'gen'))
);
// Extract member path: a.b.c -> Some(['a', 'b', 'c'])
AST.memberPath(memberNode);
// Match imports by string or predicate
declare const importNode: ESTree.ImportDeclaration;
AST.matchImport(importNode, (src) => src.startsWith('node:'));import { Diagnostic } from 'effect-oxlint';
// Basic diagnostic
const diag = Diagnostic.make({ node, message: 'Avoid this pattern' });
// With autofix
const fixed = Diagnostic.withFix(
diag,
Diagnostic.replaceText(node, 'replacement')
);
// Compose multiple fixes
const multiFix = Diagnostic.composeFixes(
Diagnostic.insertBefore(node, 'prefix'),
Diagnostic.insertAfter(node, 'suffix')
);effect-oxlint re-exports all @oxlint/plugins types so consumers don't need a direct dependency for type imports:
import type { ESTree, OxlintPlugin, CreateRule } from 'effect-oxlint';
// ESTree namespace includes all AST node types
const node: ESTree.CallExpression = /* ... */;effect-oxlint ships a Testing module with mock AST builders, rule runners, and assertion helpers.
import { describe, expect, test } from '@effect/vitest';
import * as Option from 'effect/Option';
import { Rule, Testing } from 'effect-oxlint';
describe('no-json-parse', () => {
test('reports JSON.parse', () => {
const result = Testing.runRule(
noJsonParse,
'MemberExpression',
Testing.memberExpr('JSON', 'parse')
);
Testing.expectDiagnostics(result, [{ message: 'Use Schema for JSON' }]);
// Or use the messages() helper — returns Option per diagnostic
expect(Testing.messages(result)).toEqual([
Option.some('Use Schema for JSON')
]);
});
test('ignores other member expressions', () => {
const result = Testing.runRule(
noJsonParse,
'MemberExpression',
Testing.memberExpr('console', 'log')
);
Testing.expectNoDiagnostics(result);
});
});// newExpr accepts a string — auto-wrapped in id()
Testing.newExpr('Date'); // equivalent to Testing.newExpr(Testing.id('Date'))
// ifStmt params are all optional — useful for enter/exit tracking tests
Testing.ifStmt(); // minimal IfStatement node
// program() accepts a comments parameter for comment-based rules
Testing.program(
[Testing.exprStmt(Testing.callExpr('foo'))],
[Testing.comment('Line', ' eslint-disable')]
);Available builders include id, memberExpr, computedMemberExpr, chainedMemberExpr, callExpr, callOfMember, importDecl, newExpr, throwStmt, tryStmt, ifStmt, program, objectExpr, and more.
| Module | Purpose |
|---|---|
Rule |
Core rule builder (define, meta, banMember, banImport, banStatement, banCallOf, banNewExpr, banMultiple) |
Visitor |
Composable visitors (on, onExit, merge, tracked, filter, accumulate) |
AST |
Option-returning pattern matchers (matchMember, matchCallOf, matchImport, narrow, memberPath, findAncestor) |
Diagnostic |
Diagnostic construction and autofix helpers |
RuleContext |
Effect service with access to file info, source code, and report |
SourceCode |
Effectful queries: text, tokens, comments, scope, node location |
Scope |
Variable lookup and reference analysis with Option |
Plugin |
define and merge for plugin assembly |
Comment |
Comment type predicates (isLine, isBlock, isJSDoc, isDisableDirective) |
Token |
Token type predicates (isKeyword, isPunctuator, isIdentifier, isString) |
Testing |
Mock builders, runRule, expectDiagnostics, messages for test harnesses |
bun install # install dependencies
bun run check # lint + format + typecheck (auto-fix)
bun run test # run all tests
bun run typecheck # tsgo type-check only
# Single test file
bunx vitest run test/Rule.test.ts
# By test name
bunx vitest run -t "reports for matching"See CONTRIBUTING.md.
MIT