eslint-plugin-metz
v1.0.0
Published
ESLint rules for Sandi Metz's rules: counted-line limits, parameter limits, one component per file, one view model per screen, and reasoned waivers.
Maintainers
Readme
eslint-plugin-metz
ESLint rules for Sandi Metz's rules for developers: small files, five-line functions, four parameters (option keys count), one component per file, one view model per screen, and waivers that carry a written reason. For ESLint 10 flat config, in JavaScript and TypeScript, with React and React Native support.
- Install
- Usage
- Adopting in an existing codebase
- Presets
- Overrides
- Customizing
- Rules
- The directive checker
- Migration
- Why these rules
- Versioning
Install
Requirements: ESLint 10 (flat config only), Node.js ^22.13.0 || >=24 (Node 20 reached end of
life in April 2026), and @typescript-eslint/parser 8 with TypeScript, which the rules and the
directive checker parse with.
The package is ESM-only, and ESLint loads it from any config file. From CommonJS, require() (Node
22.12 and later) returns the module namespace, so take the plugin from default:
const metz = require('eslint-plugin-metz').default;.
pnpm add -D eslint-plugin-metz eslint typescript-eslint @typescript-eslint/parser typescript@~6.0typescript-eslint is what the Usage examples configure TypeScript with; the plugin
itself needs only the parser. TypeScript stays on 6.0 (the package's typescript peer range), since
typescript-eslint 8 does not support TypeScript 7 yet.
Releases published to npm from GitHub Actions carry
provenance; check them with
npm audit signatures.
Usage
// eslint.config.js
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import metz from 'eslint-plugin-metz';
export default defineConfig([
tseslint.configs.recommended,
metz.configs.strict, // or 'recommended', 'strict-react', 'recommended-react'
]);Each preset is an array of config objects. defineConfig flattens nested arrays; a plain exported
array does not, so there you spread the preset (an unspread one fails with TypeError: Unexpected
array):
// eslint.config.js, without defineConfig
import tseslint from 'typescript-eslint';
import metz from 'eslint-plugin-metz';
export default [...tseslint.configs.recommended, ...metz.configs.strict];Every preset registers the plugin under metz, so its rules are metz/<rule>, and you can also
name it yourself (plugins: { metz }): the presets reference the same plugin object.
Adopting in an existing codebase
A codebase that has never been linted this strictly fails on day one. ESLint's bulk suppressions freeze today's findings, so only new code is held to the limits, and a team then pays the old findings down as it touches files.
- Freeze today's findings. With the
strictorstrict-reactpreset on (ESLint suppresses only rules at"error", so the warn-level presets freeze nothing), run, in each directory ESLint runs in,npx eslint . --suppress-all(or--suppress-rule metz/max-counted-linesfor one rule at a time). It writeseslint-suppressions.json; commit it. The file is generated data: never hand-edit it and never add to it. - New code meets the limits from day one. A new finding fails lint; only the frozen ones are suppressed. A suppression is a count per file and rule, so it never admits a new finding in a file beyond the frozen count.
- Touch it, fix it. A file a change modifies ends the change fully clean: fix every metz
finding in it, then drop its entries with
npx eslint . --prune-suppressionsin the directory whose suppressions file lists it, and commit the pruned file. ESLint fails when a suppression no longer matches a finding ("There are suppressions left that do not occur anymore"; the--pass-on-unpruned-suppressionsflag turns that off), which catches entries left behind after a file is cleaned. It does not force a touched file to be cleaned: a file whose findings are all still there passes. - Enforce touch-it-fix-it in CI. This script fails when a file the branch adds, modifies,
copies or renames still has an entry in any
eslint-suppressions.json. Entries are keyed by the path they were recorded under, relative to the directory ESLint ran in, so a rename counts under both its old and new path; deletions are skipped. Save it asscripts/check-touched-files-clean.mjsand run it from anywhere in the repository on pull requests with full history (fetch-depth: 0), where the base is the branch the pull request targets.
// Fails when a file this branch adds, modifies, copies or renames still has an entry in an
// eslint-suppressions.json. Usage: node check-touched-files-clean.mjs [base-ref] (default origin/main).
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 26 });
const base = process.argv[2] ?? 'origin/main';
process.chdir(git('rev-parse', '--show-toplevel').trim()); // git prints root-relative paths
// `-z` output: a status, then one path (two for a rename or copy: old, then new).
function takeEntry(tokens) {
const status = tokens.shift();
return { status, paths: tokens.splice(0, /^[RC]/.test(status) ? 2 : 1) };
}
function parseNameStatus(output) {
const tokens = output.split('\0');
const entries = [];
while (tokens.length > 1) entries.push(takeEntry(tokens));
return entries;
}
function touchedPaths() {
const diff = git('diff', '--name-status', '-z', '-M', '-C', `${base}...HEAD`);
const kept = parseNameStatus(diff).filter(({ status }) => !status.startsWith('D'));
return new Set(kept.flatMap(({ paths }) => paths));
}
// Entries are keyed relative to the directory ESLint ran in, which holds the file (`..` allowed).
function recordedPaths(file) {
const dir = path.posix.dirname(file);
return Object.keys(JSON.parse(readFileSync(file, 'utf8'))).map((key) =>
path.posix.join(dir, key),
);
}
function suppressionFiles() {
return git('ls-files', '-z', '--', '*eslint-suppressions.json').split('\0').filter(Boolean);
}
const touched = touchedPaths();
const stale = suppressionFiles()
.flatMap(recordedPaths)
.filter((key) => touched.has(key));
if (stale.length > 0) {
console.error(
`Touched files still suppressed (fix them, then run eslint --prune-suppressions):\n${stale.map((f) => ` - ${f}`).join('\n')}`,
);
process.exit(1);
}
console.log(`Clean: no file changed since ${base} has a suppression entry.`);node scripts/check-touched-files-clean.mjs origin/mainSuggested instructions for CLAUDE.md or AGENTS.md
Paste this into the file your coding agents read, and adjust the paths and commands to your project:
### Sandi Metz limits
- `eslint-plugin-metz` (preset `strict`; `strict-react` for React code) enforces Sandi Metz's limits
as errors: files of at most 100 counted lines, functions of at most 5, a component's logic of at
most 5 and its JSX of at most 15, at most 4 parameters (an options object counts one per key),
and every waiver carries a written reason. In React code also one component per file, JSX nested
at most 3 deep, and one view-model hook per screen. Test files get 150 lines a file and 10 a test.
- Findings that existed when the plugin was adopted are frozen in `eslint-suppressions.json`
(one per directory ESLint runs in). It is generated data: never hand-edit it and never add to it.
- Any file a change touches must be made fully clean: fix every metz finding in it, then drop its
entries by running `npx eslint . --prune-suppressions` in the directory whose suppressions file
lists it, and commit the pruned file. A stale entry fails lint.
- `node scripts/check-touched-files-clean.mjs origin/main` enforces this in CI: it fails when a file
changed since the base branch still has a suppression entry.
- Never raise a limit, weaken the lint config or add a waiver to get a run green. A waiver needs the
owner's explicit approval, one site at a time.Presets
| Preset | Severity | Limits | Waiver policy | React rules |
| ------------------- | -------- | --------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------- |
| strict | error | Sandi Metz's numbers: file 100, function 5, 4 parameters; a component's logic 5, JSX 15 | on | — |
| recommended | warn | the same numbers | off | — |
| strict-react | error | as strict | on | one component per file, JSX depth 3, one view-model hook |
| recommended-react | warn | as recommended | off | as strict-react, as warnings |
Every preset also carries the override blocks: longer limits for test files, and none
for tool config files and .d.ts files.
strictis Sandi Metz's numbers as errors, with rule zero enforced bymetz/waiver-format. The component limits (logic 5, JSX 15) are part ofmax-counted-lines, so they apply in every preset.recommendedis the gentle way in: the same numbers, as warnings, and no waiver policy. Warnings show every place the code breaks a rule, in the editor and ineslintoutput, without failing a run (ESLint: "warn" is for a rule you intend to make an error); the numbers stay Metz's, because loosened numbers would be invented ones. See Migration for ratcheting it down tostrict.- The React variants add
metz/component-shape(one component per file, no render helpers, JSX depth 3) on.jsx/.tsxfiles andmetz/screen-view-modelon screen files (*Screen.jsx/*Screen.tsx), test files excepted.
Every preset is an array of named flat-config objects (spread it into a plain config array; see Usage). A change to any preset follows Versioning.
Overrides
Each preset includes override blocks for the usual exceptions, each named, each with an exported glob:
| Block | Files (export) | What changes |
| -------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| metz/<preset>/tests | TEST_FILES: **/*.{test,spec}.*, **/__tests__/** | max-counted-lines with TEST_LIMITS: file 150, function 10, describe/suite callbacks exempt |
| metz/<preset>/config-files | CONFIG_FILES: **/*.config.{js,cjs,mjs,ts,cts,mts} | max-counted-lines and max-params off: tool config files are data |
| metz/<preset>/declarations | DECLARATION_FILES: **/*.d.{ts,mts,cts} | max-counted-lines and max-params off: their shape is someone else's API |
| metz/<preset>-react/components | JSX_FILES: **/*.{jsx,tsx}, less TEST_FILES | component-shape on |
| metz/<preset>-react/screens | SCREEN_FILES: **/*Screen.{jsx,tsx}, less TEST_FILES | screen-view-model on |
CONFIG_FILES is narrow on purpose: a JSX file named *.config.tsx and a dotfile such as
.babelrc.ts hold code, so they keep every limit. It matches at any depth: src/app.config.ts
or src/routes.config.ts is exempt from the line and parameter limits too. To exempt only the tool
config files at your repository root, drop the block by name and add your own:
...metz.configs.strict.filter((block) => block.name !== 'metz/strict/config-files'),
{
files: ['*.config.{js,cjs,mjs,ts,cts,mts}'],
rules: { 'metz/max-counted-lines': 'off', 'metz/max-params': 'off' },
},SCREEN_FILES follows screen-view-model's
default screenSuffix (Screen): if you change the suffix, replace the screens block with your
own glob. The exported globs and TEST_LIMITS are frozen, because every preset uses those very
values; spread them into your own arrays.
To extend an override, add a block with the exported values; to replace one, drop it by name and add your own:
import metz, { TEST_FILES, TEST_LIMITS } from 'eslint-plugin-metz';
export default defineConfig([
// Drop the test block, then treat stories like tests too.
metz.configs.strict.filter((block) => block.name !== 'metz/strict/tests'),
{
files: [...TEST_FILES, '**/*.stories.tsx'],
rules: { 'metz/max-counted-lines': ['error', { ...TEST_LIMITS, function: 12 }] },
},
]);Static presets plus exported building blocks are what ESLint's plugin guide describes (configs are
plain flat-config objects under configs), and they compose with defineConfig, extends and
files like any other config, so no factory function is needed.
Customizing
Every limit is a rule option, validated by the rule's schema: a misspelt option is an error.
To set a limit once for every preset block, use ESLint's
shared settings,
namespaced under metz and keyed by rule, with exactly the rule's options:
export default defineConfig([
metz.configs.strict,
{
settings: {
metz: {
'max-counted-lines': { file: 120 },
'max-params': { bagPattern: '(Props|Options|Config)$' },
'waiver-format': { marker: 'WAIVER:', minReasonLength: 30 },
},
},
},
]);Precedence, for each option on its own:
- the rule's own option in the config (
'metz/max-params': ['error', { max: 3 }]); - the shared setting
settings.metz["<rule>"].<option>; - the rule's default (Sandi Metz's number).
So the presets' test block, which sets TEST_LIMITS as rule options, keeps them over shared
settings, and shared settings fill in the rest. Set limits in shared settings, not as rule
options in a later block: ESLint replaces a rule's options wholesale, so a later
'metz/max-counted-lines': ['error', { file: 120 }] with no files also replaces TEST_LIMITS for
test files (back to function 5, describe no longer exempt).
Shared settings are validated against the same schema as the rule's options; an unknown rule name,
an unknown option (toString included) or a value of the wrong type throws when the rule runs.
Settings of a rule that no block turns on are not read, so they are not checked either.
Rules
💼 Configurations enabled in.
⚠️ Configurations set to warn in.
✅ Set in the recommended configuration.
⚛️ Set in the recommended-react configuration.
🔒 Set in the strict configuration.
🛡️ Set in the strict-react configuration.
⚙️ Has configuration options.
| Name | Description | 💼 | ⚠️ | ⚙️ | | :--------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :---- | :---- | :-- | | component-shape | Enforce one component per file, no render helpers or nested components, and a JSX depth limit | 🛡️ | ⚛️ | ⚙️ | | max-counted-lines | Enforce counted-line limits per file, function and component (Sandi Metz rules 1 and 2) | 🔒 🛡️ | ✅ ⚛️ | ⚙️ | | max-params | Enforce a parameter limit that counts option-object keys and props one each (Sandi Metz rule 3) | 🔒 🛡️ | ✅ ⚛️ | ⚙️ | | screen-view-model | Require a screen to call exactly one view-model hook and never reach through it (Sandi Metz rule 4) | 🛡️ | ⚛️ | ⚙️ | | waiver-format | Require every rule waiver to be a next-line disable with a written reason (Sandi Metz rule zero) | 🔒 🛡️ | | ⚙️ |
The directive checker
metz/waiver-format is editor feedback: an inline comment can switch it off. The gate is a CLI that
reads every comment itself, the way ESLint does, and is a fail-closed allow-list: the only ESLint
directive allowed is eslint-disable-next-line <named rules> -- <marker> <reason>. It reports block
and same-line disables, eslint-enable, inline config comments, a rule-less disable, any directive
naming the waiver rule itself (whatever --guarded-prefix says), and any eslint… comment ESLint
cannot parse. With --guarded-prefix, a directive naming only unguarded rules is left alone.
metz-check-directives [--min-reason-length <n>] [--marker <text>] \
[--guarded-prefix <prefix>]... [--waiver-rule <rule>] <path>...It checks every .ts, .tsx, .mts, .cts, .js, .jsx, .mjs and .cjs file under each
directory once (overlapping paths are read once), skipping node_modules, .git and .stryker-tmp everywhere and dist, coverage,
.turbo, .expo and .wrangler beside a package.json (name a directory explicitly to check one
of those). A file named on the command line must have one of those extensions. A dangling link
named like a code file is reported as a finding (its target is missing, so it cannot be checked);
any other dangling link is ignored. --marker and
--waiver-rule must not be empty. Exit status: 0 clean, 1 forbidden directives found, 2 a usage
error, a missing or unsupported path, or a symlink loop. Run it in CI next to ESLint:
"scripts": { "lint": "eslint . && metz-check-directives src test" }The same check is a function: import { findForbiddenDirectives } from 'eslint-plugin-metz/directives'.
Migration
Adopting the rules in an existing codebase:
- See the size of it. Start with
recommended(orrecommended-react): every break is a warning, and nothing fails. - Stop the growth. Run ESLint with
--max-warnings <today's count>in CI, and lower the number as you go. - Switch to
strict, with the waiver policy still off, when the warnings are gone: add{ rules: { 'metz/waiver-format': 'off' } }after it. To switch earlier, use ESLint's bulk suppressions: withstricton, runeslint --suppress-allonce and commiteslint-suppressions.json; new code must pass, existing violations stay recorded, andeslint --prune-suppressionsdrops each one you fix. (Suppressions apply only to rules at"error", which is why they pair withstrict.) - Turn the waiver policy on last: remove that
'off'line, rewrite each remaining disable as a reasonedeslint-disable-next-line, and addmetz-check-directivesto CI.
To raise one limit while you migrate, set it in shared settings and lower it step by step.
Why these rules
In 2013 Sandi Metz gave the developers she was coaching four rules, as recorded by Caleb Hearth in Sandi Metz' Rules For Developers (thoughtbot):
- "Classes can be no longer than one hundred lines of code."
- "Methods can be no longer than five lines of code."
- "Pass no more than four parameters into a method. Hash options are parameters."
- "Controllers can instantiate only one object. Therefore, views can only know about one instance variable and views should only send messages to that object."
And rule zero: "You should break these rules only if you have a good reason or your pair lets you."
The rules stand in for what Practical Object-Oriented Design asks of code: small public
interfaces, single responsibilities, visible dependencies, code that is cheap to change. This
plugin measures the first three in counted lines of Prettier-formatted code
(max-counted-lines) and in parameters with option keys counted
(max-params); treats a React component as the "class" of a UI
(component-shape); reads rule 4 for screens and their view-model
hooks (screen-view-model); and turns rule zero into reasoned
waivers (waiver-format). The rules are Sandi Metz's; this plugin is
an independent implementation and is not endorsed by her.
Versioning
This is the package's one semver policy; the changelog links here. The package follows Semantic Versioning:
| Kind | The change |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Major | Anything that can make a passing lint fail or a config stop loading: a new rule in a preset; a stricter value in a preset or a changed default; a new error a rule reports on code it accepted; a removed or renamed option, rule, preset, override block, glob or export; a narrowed glob; a raised minimum ESLint, Node, @typescript-eslint/parser or TypeScript version. |
| Minor | A new rule outside the presets; a new option whose default keeps today's behaviour; a new preset or export; fewer reports. |
| Patch | A false positive or a crash fixed; docs. |
Every change is recorded in CHANGELOG.md.
License
MIT © Ryan Crowley
