npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

effect-oxlint

v0.3.2

Published

Effect-first library for writing oxlint custom lint rules

Readme

effect-oxlint

npm JSR License: MIT

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 — write create as an Effect generator; use yield* for state, context, and diagnostics
  • Visitor.* — composable visitor combinators: on, onExit, merge, tracked, filter, accumulate
  • AST.*Option-returning matchers with dual API (data-first and data-last): matchMember, matchCallOf, matchImport, narrow, memberPath
  • Diagnostic.* — structured diagnostic builders with composable autofixes
  • SourceCode.* / Scope.* — effectful queries over tokens, comments, scope, and variables
  • Testing.* (from effect-oxlint/testing) — mock builders, rule runners, and assertion helpers for @effect/vitest
  • Plugin.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-oxlint

Quick 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 exit

Accumulate 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