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

@mpsuesser/oxlint-plugin-alchemy

v0.2.2

Published

Oxlint JS plugin enforcing Alchemy-first development conventions

Readme

oxlint-plugin-alchemy

npm JSR License: MIT

An opinionated oxlint plugin for Alchemy v2 that drives your IaC codebase toward idiomatic, safe, and maintainable resource definitions — v1→v2 migration footguns, Cloudflare conventions, Output-safety, and file-structure expectations encoded as lint rules.

The plugin ships 14 rules (AL-1 through AL-14). Rules are implemented with the effect-oxlint SDK and run as standard oxlint custom rules.

Installation

npm install -D @mpsuesser/oxlint-plugin-alchemy
# or
bun add -D @mpsuesser/oxlint-plugin-alchemy

Use the generated recommended config from oxlint.config.ts:

import { defineConfig } from 'oxlint';
import alchemy from '@mpsuesser/oxlint-plugin-alchemy';

export default defineConfig({
	extends: [alchemy.configs.recommended]
});

configs.recommended registers the package through oxlint's jsPlugins field and enables all 14 rules at error severity.

To override an individual rule, add a rules entry after the extends block:

import { defineConfig } from 'oxlint';
import alchemy from '@mpsuesser/oxlint-plugin-alchemy';

export default defineConfig({
	extends: [alchemy.configs.recommended],
	rules: {
		'@mpsuesser/alchemy/no-console-log-output': 'off',
		'@mpsuesser/alchemy/no-v1-import-paths': 'warn'
	}
});

If you use .oxlintrc.json, oxlint cannot import a package config object. Configure the JS plugin and any rules you want explicitly:

{
	"jsPlugins": ["@mpsuesser/oxlint-plugin-alchemy"],
	"rules": {
		"@mpsuesser/alchemy/no-v1-import-paths": "error"
	}
}

Use oxlint.config.ts when you want the full generated recommended config.

Rules at a glance

| Rule | What it catches | | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | no-v1-await-stack | await alchemy("name", {}) v1 stack-creation pattern | | no-v1-finalize | app.finalize() — v2 stacks finalize automatically | | no-v1-await-resource | await Cloudflare.R2Bucket(...) — resources are Effects, use yield* | | no-v1-import-paths | from "alchemy/cloudflare" (lowercase) — v2 uses PascalCase subpaths | | no-v1-entrypoint-prop | Worker("W", { entrypoint }) — renamed to main: in v2 | | no-shouty-binding-keys | bindings: { BUCKET: bucket } — v2 uses PascalCase shorthand | | require-stable-logical-id | Dynamic logical IDs — state keys must be string literals | | prefer-namespace-imports | Named or non-canonical imports from Alchemy provider subpaths | | bind-in-init-only | Resource.bind(...) inside a per-request fetch: handler | | no-shadowing-global-worker | class Worker {} / function Worker() {} | | no-string-concat-output | + / template-literal concat of Output-shaped values | | no-console-log-output | console.* calls passed Output-shaped values | | stack-in-alchemy-run-file | export default Alchemy.Stack(...) outside alchemy.run.ts(x) | | platform-main-import-meta-path-when-collocated | Default-exported Worker/Container with a hardcoded main: path |


AL-1 · no-v1-await-stack

await alchemy(...) was the v1 stack-creation pattern. v2 stacks are pure Effects — declared with Alchemy.Stack(...), finalized automatically, and run by the CLI.

// ❌  v1 stack pattern
const app = await alchemy('my-app', { stage: 'dev' });
const bucket = await Cloudflare.R2Bucket('Bucket');
await app.finalize();

// ✅  v2 stack pattern
export default Alchemy.Stack(
	'my-app',
	{ providers: Cloudflare.providers() },
	Effect.gen(function* () {
		const Bucket = yield* Cloudflare.R2Bucket('Bucket');
		return { Bucket };
	})
);

AL-2 · no-v1-finalize

v2's Alchemy.Stack(...) finalizes automatically. A leftover app.finalize() either throws (the method no longer exists on the v2 return type) or silently no-ops on stale typings.

// ❌
await app.finalize();
stack.finalize();

// ✅
export default Alchemy.Stack('App', { providers }, Effect.gen(function* () { ... }));

The rule is intentionally narrow: zero-argument .finalize() on a bare-identifier receiver. Unrelated APIs like crypto.subtle.finalize(arg) (deep chain, takes args) are not flagged.

AL-3 · no-v1-await-resource

In v2, resource constructors are Effects, not Promises. They're consumed with yield* inside the Stack generator.

// ❌
const bucket = await Cloudflare.R2Bucket('Bucket');
const queue = await AWS.SQS.Queue('Jobs', { fifoQueue: true });
const fn = await AWS.Lambda.Function('Api', { main: './api.ts' });
const token = await Alchemy.Random('Token');

// ✅
const Bucket = yield * Cloudflare.R2Bucket('Bucket');
const Queue = yield * AWS.SQS.Queue('Jobs', { fifoQueue: true });
const Fn = yield * AWS.Lambda.Function('Api', { main: './api.ts' });
const Token = yield * Alchemy.Random('Token');

"Resource constructor" is detected heuristically: a member chain rooted in an Alchemy provider namespace (Cloudflare, AWS, Alchemy, PlanetScale, …) that ends in a PascalCase identifier. await Cloudflare.providers() (lowercase final segment) and await myLib.helper() (non-Alchemy root) are not flagged.

AL-4 · no-v1-import-paths

v1 imports used lowercase subpaths (alchemy/cloudflare). v2 uses PascalCase subpaths matching the namespace identifier.

// ❌
import * as Cloudflare from 'alchemy/cloudflare';
import * as AWS from 'alchemy/aws';
import { R2Bucket } from 'alchemy/cloudflare/r2';

// ✅
import * as Cloudflare from 'alchemy/Cloudflare';
import * as AWS from 'alchemy/AWS';

The root 'alchemy' package and PascalCase subpaths ('alchemy/Cloudflare', 'alchemy/AWS', …) are unaffected. Unrelated packages are ignored entirely.

AL-5 · no-v1-entrypoint-prop

Worker and Container previously accepted an entrypoint: prop. v2 renamed it to main: — passing the old name is silently ignored and your deploy ships the wrong bundle.

// ❌
Cloudflare.Worker('Api', {
	entrypoint: './src/api.ts',
	bindings: { Bucket }
});
Cloudflare.Container('Sidecar', { entrypoint: './c.ts' });

// ✅
Cloudflare.Worker('Api', {
	main: './src/api.ts',
	bindings: { Bucket }
});
Cloudflare.Container('Sidecar', { main: './c.ts' });

Only fires for Cloudflare.Worker(...) / Cloudflare.Container(...) — non-platform constructors that happen to accept an entrypoint prop are unaffected.

AL-6 · no-shouty-binding-keys

v1 used SHOUTY_SNAKE_CASE keys in bindings: { ... } to mirror Wrangler's env shape. v2 uses PascalCase shorthand matching the resource identifier so InferEnv can derive the typed env directly.

// ❌
Cloudflare.Worker('Api', {
	bindings: {
		BUCKET: bucket,
		KV: sessions,
		API_KEY: apiKey
	}
});

// ✅  Shorthand — the resource identifier doubles as the key.
Cloudflare.Worker('Api', {
	bindings: { Bucket, Sessions, ApiKey }
});

Only fires inside Cloudflare.Worker(...) / Cloudflare.Container(...) calls. Non-shouty keys (bucketName, Bucket) pass through.

AL-7 · require-stable-logical-id

A resource's first argument is its logical ID — the state key that pins the resource across deploys. A dynamic ID silently recreates or orphans the underlying cloud resource on every run.

// ❌  Dynamic ID — state desyncs across deploys.
Cloudflare.R2Bucket(variableId);
Cloudflare.R2Bucket(`prefix-${stage}`);
AWS.SQS.Queue(jobName);
Alchemy.Stack(varName);

// ✅
Cloudflare.R2Bucket('Bucket');
AWS.SQS.Queue('Jobs', { fifoQueue: true });
Alchemy.Stack('app', { providers }, Effect.gen(function* () { ... }));

For per-deploy variation in a resource's cloud-side name (not its logical ID), put the dynamic bit in the props with Stack.useSync(({ stage }) => ({ name: ${stage}-thing })). Method calls like Cloudflare.R2Bucket.bind(Bucket) and namespace helpers like Cloudflare.providers() are not flagged (final segment isn't PascalCase or is camelCase).

AL-8 · prefer-namespace-imports

Alchemy's docs, JSDoc examples, and rule diagnostics all assume the namespace-import shape: import * as Cloudflare from 'alchemy/Cloudflare', then Cloudflare.Worker(...). Named imports or non-canonical aliases break that grep-ability.

// ❌  Named imports from a provider submodule.
import { R2Bucket, Worker } from 'alchemy/Cloudflare';

// ❌  Non-canonical alias.
import * as CF from 'alchemy/Cloudflare';

// ✅
import * as Cloudflare from 'alchemy/Cloudflare';
import * as AWS from 'alchemy/AWS';
import * as Output from 'alchemy/Output';

Type-only imports (import type { ... } and import { type X }) are exempt — TypeScript erases them and they don't affect the runtime shape. The root 'alchemy' package and 'alchemy/Stack' (service-tag pattern) are also unaffected.

AL-9 · bind-in-init-only

Bindings are set up once at plan-time. Calling Resource.bind(...) inside a per-request fetch: handler defeats the init/exec phase split and pushes SDK setup into every request.

// ❌  bind() inside the request handler.
return Cloudflare.Worker('Api', {
	main: import.meta.path,
	bindings: { Bucket },
	fetch: (request, env) => {
		Cloudflare.R2Bucket.bind(Bucket);
		return new Response('ok');
	}
});

// ✅  bind() in the outer init Effect; fetch reads from env.
const Bucket = yield * Cloudflare.R2Bucket('Bucket');
Cloudflare.R2Bucket.bind(Bucket);
return Cloudflare.Worker('Api', {
	main: import.meta.path,
	bindings: { Bucket },
	fetch: (request, env) => env.Bucket.get('key')
});

The rule uses depth tracking over fetch: property boundaries — only <Namespace>.<Resource>.bind(...) calls inside a fetch: handler are flagged. Unrelated .bind(...) calls (handler.bind(this)) are passed through.

AL-10 · no-shadowing-global-worker

The Cloudflare Workers runtime exposes Worker as a global. A local class Worker {} or function Worker() {} declaration shadows it and breaks type inference on the runtime binding shape.

// ❌
class Worker {
	fetch(request: Request) { ... }
}

function Worker() {
	return new Response('ok');
}

// ✅  Rename, or use a namespace alias to refer to Alchemy's Worker.
class MyWorker { ... }
import * as Cloudflare from 'alchemy/Cloudflare';
Cloudflare.Worker('Api', { ... });

AL-11 · no-string-concat-output

Output<string> is a lazy reference, not a string. Concatenating it with + or interpolating it in a plain template literal silently calls .toString() and produces garbage like "[object Object]" in deployed config.

// ❌
const url = 'https://' + bucket.bucketName + '.r2.dev';
const path = `${bucket.bucketName}/uploads`;
const dsn = `${db.databaseUrl}?sslmode=require`;

// ✅  Tagged template preserves the lazy expression.
const url = Output.interpolate`https://${bucket.bucketName}.r2.dev`;
const path = Output.interpolate`${bucket.bucketName}/uploads`;

"Output-shaped" is detected via property-name heuristics: member accesses whose final segment ends in Name, Url, Arn, Id, Endpoint, Host, Region, Port, or Key. Plain-string concatenation ('a' + 'b') and non-+ operators (count - 1) are not flagged.

AL-12 · no-console-log-output

console.* renders Output<string> as [Output] — not the resolved value. Return Outputs from your Stack generator so alchemy deploy can print the resolved values after the run.

// ❌
console.log(bucket.bucketName);
console.error('failed for url:', db.databaseUrl);
console.info(`queue: ${queue.queueArn}`);

// ✅  Return outputs from the stack; alchemy resolves and prints them.
export default Alchemy.Stack(
	'app',
	{ providers },
	Effect.gen(function* () {
		const Bucket = yield* Cloudflare.R2Bucket('Bucket');
		return { bucketName: Bucket.bucketName };
	})
);

Uses the same Output-shape heuristic as AL-11. Non-console receivers (logger.log(...)), bare identifiers, and non-Output properties (item.count) are not flagged.

AL-13 · stack-in-alchemy-run-file

Alchemy.Stack(...) is the stack entry point. The CLI, CI workflows, and the docs all assume it lives at alchemy.run.ts (or .tsx). Stacks default-exported from anywhere else won't be picked up.

// ❌  src/main.ts
export default Alchemy.Stack('app', { providers }, Effect.gen(function* () { ... }));

// ❌  app/deploy.ts
export default Alchemy.Stack('app', ...);

// ✅  alchemy.run.ts
export default Alchemy.Stack('app', { providers }, Effect.gen(function* () { ... }));

The rule gates its visitor on the filename, so files not ending in alchemy.run.ts(x) skip the check entirely. Default-exported resource constructors (export default Cloudflare.Worker(...)) are unaffected — they're the subject of AL-14.

AL-14 · platform-main-import-meta-path-when-collocated

When a file export defaults a Cloudflare.Worker(...) or Cloudflare.Container(...), main: should be import.meta.path so the file can move or rename without manual path updates.

// ❌  src/api.ts
export default Cloudflare.Worker('Api', {
	main: './src/api.ts', // ← hardcoded path
	bindings: { Bucket }
});

// ✅  src/api.ts
export default Cloudflare.Worker('Api', {
	main: import.meta.path,
	bindings: { Bucket }
});

Only fires for default-exported Cloudflare.Worker(...) / Cloudflare.Container(...) calls. Worker definitions referenced elsewhere (passed as a value, registered in a stack) are unaffected. Calls without a main: prop at all are also unaffected — there's nothing to constrain.


Suppression

All rules respect oxlint's standard disable directives:

// oxlint-disable-next-line @mpsuesser/alchemy/<rule-name> -- reason

/* oxlint-disable @mpsuesser/alchemy/<rule-name> -- reason */
// ... block ...
/* oxlint-enable @mpsuesser/alchemy/<rule-name> */

A trailing -- <reason> comment is encouraged for any suppression that lives longer than a single PR review.

Development

bun install
bun test          # run the test suite (98 tests across 14 rules)
bun run typecheck # tsgo
bun run check     # format + lint

Each rule lives in src/rules/<rule-name>.ts with a sibling test in test/rules/<rule-name>.test.ts. The rule SDK is documented at effect-oxlint.

License

MIT