effect-oxlint
v0.3.2
Published
Effect-first library for writing oxlint custom lint rules
Maintainers
Readme
effect-oxlint
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.
Features
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.*(fromeffect-oxlint/testing) — mock builders, rule runners, and assertion helpers for@effect/vitestPlugin.define— assemble rules into a plugin that oxlint can load
Install
npm install effect-oxlint [email protected]bun add effect-oxlint [email protected]deno add jsr:@effect-oxlint/effect-oxlintQuick Start
1. Define a rule
import * 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'
})
)
}
)
};
}
});2. Use convenience factories for common patterns
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 obj.prop(...) method calls (e.g. Effect.runSync, console.log)
const noRunSync = Rule.banCallOfMember('Effect', ['runSync', 'runPromise'], {
message: 'Keep effects composable — run only at the entry point'
});
// 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' }
);3. Assemble into a plugin
import { Plugin } from 'effect-oxlint';
export default Plugin.define({
name: 'my-effect-rules',
specifier: 'oxlint-plugin-my-effect-rules',
rules: {
'no-json-parse': noJsonParse,
'no-math-random': noMathRandom,
'no-node-fs': noNodeFs,
'no-throw': noThrow
}
});specifier should match the npm package name users put in oxlint's jsPlugins array. When it is present, Plugin.define also creates configs.recommended and configs.all for oxlint.config.ts users:
import { defineConfig } from 'oxlint';
import plugin from 'oxlint-plugin-my-effect-rules';
export default defineConfig({
extends: [plugin.configs.recommended]
});The generated configs use oxlint's jsPlugins field plus explicit fully-qualified rule names. They do not rely on ESLint-only docs.recommended metadata or oxlint native categories.
By default, every rule is recommended at error severity. To publish a curated recommended set, pass recommended.rules; TypeScript checks those names against the keys of rules, so typos fail during plugin development:
export default Plugin.define({
name: 'my-effect-rules',
specifier: 'oxlint-plugin-my-effect-rules',
rules: {
'no-json-parse': noJsonParse,
'no-math-random': noMathRandom
},
recommended: {
severity: 'warn',
rules: ['no-json-parse']
}
});Visitor Combinators
Visitors are Record<string, (node) => Effect<void>> maps. The Visitor module provides combinators to build and compose them.
Merge multiple visitors
import { Visitor } from 'effect-oxlint';
const combined = Visitor.merge(importVisitor, memberVisitor, statementVisitor);When two visitors handle the same node type, both handlers run sequentially.
Track depth with Ref
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 exitAccumulate and analyze
Collect 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
}
);Filter by filename
Restrict a visitor to specific files:
import { Visitor } from 'effect-oxlint';
const visitor =
yield *
Visitor.filter((filename) => !filename.endsWith('.test.ts'), mainVisitor);AST Matching
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:'));Diagnostics and Autofixes
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')
);Handler Error Channel
Visitor handlers — and the create generator itself — have a fixed error channel of never. That is, a rule cannot fail via Effect.fail; oxlint's plugin API is synchronous and effect-oxlint bridges it with Effect.runSync, which has no way to surface typed failures.
If a handler needs to run a fallible sub-effect, catch the failure inside the handler and decide how to surface it — typically as a reported diagnostic:
const ctx = yield * RuleContext;
const handler = (node: ESTree.Node) =>
fallibleEffect(node).pipe(
Effect.catch(() =>
ctx.report(
makeDiagnostic({ node, message: 'could not analyse node' })
)
)
);See the JSDoc on Rule.define and EffectHandler in src/Visitor.ts for the full contract.
Types
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 = /* ... */;Testing
effect-oxlint ships a Testing module with mock AST builders, rule runners, and assertion helpers. It's exposed as a dedicated subpath export so production bundles don't pull in test-only code:
import { describe, expect, test } from '@effect/vitest';
import * as Option from 'effect/Option';
import { Rule } from 'effect-oxlint';
import * as Testing from 'effect-oxlint/testing';
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);
});
});Node builders accept ergonomic shorthands
// 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.
Modules
| Module | Purpose |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Rule | Core rule builder (define, meta, banMember, banImport, banStatement, banCallOf, banCallOfMember, 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 — import from effect-oxlint/testing |
Development
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"Requirements
The npm package publishes built ESM JavaScript and declaration files under dist/, so Node-based tools such as oxlint can load plugins from node_modules without TypeScript source loading or type stripping. The JSR package continues to publish the TypeScript source entrypoints.
Supported consumers:
- Node.js / oxlint — works from the npm package exports.
- Bun — works from the npm package exports.
- Deno — use the JSR package.
effect is a peer dependency and must be installed alongside effect-oxlint at a matching version.
Contributing
See CONTRIBUTING.md.
License
MIT
