@slip-stream-kit/eslint-plugin
v0.16.0
Published
Custom ESLint rules enforcing the white-label frontend architecture conventions
Maintainers
Readme
@wl/eslint-plugin
Custom ESLint rules that enforce the white-label frontend architecture conventions.
Installation
pnpm add -D @wl/eslint-pluginUsage (flat config)
Enable everything via the recommended preset. It is an array of config blocks
(rules scoped to *.tsx, with component-file-order and props-type-name
turned off for *.stories.{ts,tsx}), so spread it:
// eslint.config.js
import wl from '@wl/eslint-plugin'
export default [...wl.configs.recommended]Or register the plugin and pick rules manually:
import wl from '@wl/eslint-plugin'
export default [
{
plugins: { '@wl': wl },
rules: {
'@wl/props-destructuring-newline': 'error',
},
},
]Rules
props-destructuring-newline
🔧 Automatically fixable.
React components must accept a single props parameter and destructure it on
its own line inside the body, instead of destructuring inline in the parameter
list.
// ❌ Incorrect
const UserCard = ({ user, className }: UserCardProps) => {
return <div className={className}>{user.name}</div>
}
// ✅ Correct
const UserCard = (props: UserCardProps) => {
const { user, className } = props
return <div className={className}>{user.name}</div>
}A function is treated as a component when its name is PascalCase (looking
through memo/forwardRef/observer wrappers) or when it returns JSX. Hooks,
plain helpers, and any function whose first parameter is not an object pattern
are ignored.
props-destructuring-blank-line
🔧 Automatically fixable.
Require a blank line after the const { ... } = props destructuring statement
at the top of a component body.
// ❌ Incorrect
const UserCard = (props: UserCardProps) => {
const { user, className } = props
return <div className={className}>{user.name}</div>
}
// ✅ Correct
const UserCard = (props: UserCardProps) => {
const { user, className } = props
return <div className={className}>{user.name}</div>
}The rule only triggers inside components (same detection as above) and only for
a statement that destructures the props identifier. It is a no-op when the
destructuring is the last statement in the body.
props-type-reference
A React component's props parameter must use a named type (e.g.
ButtonProps) rather than an inline object type literal. This keeps props types
discoverable, reusable, and consistent with the ComponentNameProps naming
convention. Report-only (it does not auto-extract the type).
// ❌ Incorrect — inline object type on the props parameter
const Button = (props: { label: string }) => <button>{props.label}</button>
function Card({ title }: { title: string }) {
return <div>{title}</div>
}
// ✅ Correct — a named type reference
const Button = (props: ButtonProps) => <button>{props.label}</button>
function Card({ title }: CardProps) {
return <div>{title}</div>
}A function is treated as a component with the same detection as the rules above
(PascalCase name through memo/forwardRef/observer wrappers, or a JSX
return). The message suggests <ComponentName>Props when the component name is
resolvable, and a generic phrasing for anonymous components.
Limitations (v1): only a bare inline object type ({ ... }) on the first
parameter is flagged. An intersection or union that merely contains a literal
(e.g. Base & { x: number }) is left alone. A default parameter value
((props: { x } = {})) is still flagged.
Option: paths / ignore (optional)
Same glob semantics as component-file-order: paths restricts the rule to
matching files, ignore skips matching files (and takes precedence over
paths).
{
rules: {
'@wl/props-type-reference': ['error', { ignore: ['**/*.stories.tsx'] }],
},
}props-type-name
A React component's props type must be named <ComponentName>Props (e.g.
ButtonProps for Button). This complements props-type-reference: that rule
requires a named type (not an inline literal); this rule requires that name to
follow the convention. Report-only.
// ❌ Incorrect — props type does not match the component name
const Button = (props: Props) => <button>{props.label}</button>
function Card({ title }: CardConfig) {
return <div>{title}</div>
}
// ✅ Correct — `<ComponentName>Props`
const Button = (props: ButtonProps) => <button>{props.label}</button>
function Card({ title }: CardProps) {
return <div>{title}</div>
}The component is detected the same way as the other rules (PascalCase name
through memo/forwardRef/observer wrappers, or a JSX return). Only a simple
named type reference on the first parameter is checked: inline object types are
the props-type-reference rule's concern, and anonymous components, untyped
props, and qualified/generic annotations (NS.Props, FC<Props>) are left
alone. An imported props type with a non-conventional name is still flagged —
use paths/ignore to exempt it.
The recommended preset turns this rule off for *.stories.{ts,tsx}: story
templates legitimately reference the component's own props type (e.g.
const Template = (args: ButtonProps) => ...) rather than <TemplateName>Props.
Option: paths / ignore (optional)
Same glob semantics as component-file-order: paths restricts the rule to
matching files, ignore skips matching files (and takes precedence over
paths).
{
rules: {
'@wl/props-type-name': ['error', { ignore: ['**/*.stories.tsx'] }],
},
}component-file-order
Enforce a strict top-level order in files that contain a React component: imports → component props interface/type → component declaration, with the props interface declared immediately before the component — no constants, helpers, or other declarations wedged between them. Helpers are allowed after the component (or between two separate component blocks). Report-only (it does not auto-reorder code).
// ❌ Incorrect — interface before imports, or component before its interface
interface CardProps { title: string }
import { cn } from '#root/lib/utils'
// ✅ Correct
import { cn } from '#root/lib/utils'
interface CardProps {
title: string
}
const Card = (props: CardProps) => {
const { title } = props
return <div className={cn('card')}>{title}</div>
}The rule activates only when the file actually contains a component. A
component's props interface is matched by the type its parameter actually
references (e.g. Props in (props: Props)), not by a name convention — so an
interface named anything is enforced, as long as the component uses it. (When the
parameter has no resolvable named type, the rule falls back to looking for a
<ComponentName>Props interface.)
When the first component's props type is imported (e.g.
import type { CardProps } from './types') instead of declared in the file,
there is no in-file interface to anchor against — so the component itself must
sit immediately after the imports, with no stray top-level definitions wedged in
between. Only the first component is anchored this way.
// ❌ Incorrect — props imported, but a stray const sits before the component
import { cn } from '#root/lib/utils'
import type { CardProps } from './types'
const SOMETHING = 1
const Card = (props: CardProps) => <div className={cn('card')}>{props.title}</div>
// ✅ Correct — component immediately after the imports
import { cn } from '#root/lib/utils'
import type { CardProps } from './types'
const Card = (props: CardProps) => <div className={cn('card')}>{props.title}</div>Option: paths (optional)
Restrict the rule to specific files via glob patterns. When omitted, it runs on
every file (you can also scope it the usual way with flat-config files).
{
rules: {
'@wl/component-file-order': ['error', { paths: ['**/features/**', 'apps/*/ui/**'] }],
},
}Glob support: * matches within a path segment, ** matches across segments,
? matches a single character. A file matches if any pattern matches its path.
component-arrow-function
React components must be declared as arrow functions, not function
declarations or function expressions. This keeps component definitions consistent
across features and components. Report-only (it does not auto-convert the
function — hoisting, generics, and default-export semantics make a safe autofix
non-trivial).
// ❌ Incorrect — function declaration
function Card(props: CardProps) {
return <div>{props.title}</div>
}
// ❌ Incorrect — function expression
const Card = function (props: CardProps) {
return <div>{props.title}</div>
}
// ✅ Correct — arrow function (memo/forwardRef wrappers are fine)
const Card = (props: CardProps) => <div>{props.title}</div>
const Memoized = memo((props: CardProps) => <div>{props.title}</div>)A function is treated as a component with the same detection as the rules above
(PascalCase name through memo/forwardRef/observer wrappers, or a JSX
return) — so a PascalCase function is flagged even when it does not return JSX.
The message names the component when resolvable and uses a generic component
phrasing for anonymous defaults (e.g. export default memo(function () { ... })).
Only top-level declarations are inspected; re-exports (export { Foo }) and
export default Foo are governed at the declaration site.
The recommended preset enables this rule for *.tsx with a default
ignore of ['**/pages/**', '**/routes/**'], since page and route modules
commonly use function declarations (and framework conventions such as
default-exported page/route functions).
Option: paths / ignore (optional)
Same glob semantics as component-file-order: paths restricts the rule to
matching files, ignore skips matching files (and takes precedence over
paths). Use ignore to exclude pages and routes (override the preset default
to add your own, e.g. an app/ router):
{
rules: {
'@wl/component-arrow-function': ['error', { ignore: ['**/pages/**', '**/routes/**', '**/app/**'] }],
},
}max-jsx-return-size
Warn when a single component return renders too many JSX elements. Large return blocks are hard to scan; the fix is to extract part of the markup into a variable or a sub-component. Report-only — the remedy is left to the developer (no autofix), because safely extracting JSX touches scope, hooks, and keys.
// ❌ Incorrect — one return renders too many elements (default max 20)
const Dashboard = () => (
<div>
<header>…</header>
<main>… lots of nested markup …</main>
<footer>…</footer>
</div>
)
// ✅ Correct — extract parts into variables or sub-components
const Dashboard = () => {
const header = <header>…</header>
const footer = <footer>…</footer>
return (
<div>
{header}
<Main />
{footer}
</div>
)
}The metric is a count of JSXElement nodes in the returned expression —
formatting-independent (Prettier reflow never changes the verdict). Each return
in a component is measured on its own, so a small guard such as
if (loading) return <Spinner /> is never penalised by a large sibling return.
Counting rules:
- Extraction lowers the count. JSX hoisted into a variable is referenced as
{header}(a JSX expression container, not aJSXElement), so it is not counted — extracting strictly reduces the number. - Fragments are free.
<>…</>contributes0; its children still count. - Inline-callback JSX counts in the parent return:
<ul>{items.map(() => <li />)}</ul>counts<ul>and<li>(extract a<Row />sub-component to reduce it). - Conditional branches are summed:
cond ? <A /> : <B />counts both sides. - JSX in attributes is counted:
<Foo icon={<Icon />} />countsFooandIcon.
Only top-level declared components are inspected (same as
component-arrow-function), so anonymous inline callbacks are never reported on
their own. A top-level JSX-returning helper (e.g. const renderRow = () => <li />)
is treated as a component and measured. The message names the component when
resolvable and uses a generic component for anonymous defaults.
Actionable message. When one block dominates the return, the message points at it — its tag, line, and element count — so a human (or an automated lint → fix → lint loop) knows exactly what to lift out:
Dashboard renders 28 JSX elements in one return (max 20). Extract the largest
block — <section> at line 14 (12 elements) — into a variable or a sub-component.When no single block dominates (e.g. many flat sibling elements), there is nothing useful to point at, so the message instead advises splitting the return into smaller sub-components.
Option: maxElements (optional)
The element ceiling before the rule reports. Defaults to 20. Only counts
strictly greater than the ceiling are reported (count === max is allowed).
{
rules: {
'@wl/max-jsx-return-size': ['error', { maxElements: 25 }],
},
}Option: paths / ignore (optional)
Same glob semantics as the other rules: paths restricts the rule to matching
files, ignore skips matching files (and takes precedence over paths).
max-components-per-file
Caps how many React components a single file may declare; extra components belong in their own files. This keeps files focused and discoverable instead of growing into multi-component junk drawers.
// ❌ Incorrect — 5 components in one file (default ceiling is 4)
const A = () => <div />
const B = () => <div />
const C = () => <div />
const D = () => <div />
const E = () => <div /> // reported here: "This file declares 5 components (max 4)"
// ✅ Correct — split the extra component into its own fileOnly top-level declarations are counted. A multi-declarator statement
(const A = () => …, B = () => …) counts each component separately. Re-exports
(export { X } from './x') declare nothing and are not counted, and
styled.div\…`tagged templates are not component functions, so they are not
counted either. Nested / in-render components are intentionally out of scope —
that is a different concern (component identity / re-render stability), better
served byreact/no-unstable-nested-components`.
Detection uses the same heuristic as the other rules (PascalCase name through
memo/forwardRef/observer wrappers, or a JSX return). A consequence worth
knowing: a PascalCase-named function that returns a non-JSX value (e.g. a factory
const Make = () => ({ … })) is counted as a component, because the name
short-circuits the check. This is consistent across the plugin.
The rule reports once per file, anchored to the first component over the limit, rather than once per excess component — there is no autofix, so a single file-scoped diagnostic is more useful than N copies of the same advice.
In the recommended preset the ceiling is 4 for *.tsx generally and tightened
to 1 for dumb *-component.tsx files (matching the one-component-per-file
convention the props/order/stories rules already assume); **/pages/** and
**/routes/** are exempt, since route/page modules legitimately co-locate
multiple route or layout components. For a file that genuinely needs to break the
ceiling, use an inline // eslint-disable-next-line @wl/max-components-per-file.
Why a custom rule (vs react/no-multi-comp)
eslint-plugin-react's no-multi-comp covers similar ground but effectively
enforces a fixed ceiling of 1 (it flags the 2nd+ component) and cannot be
configured to an arbitrary limit. This rule exists because it (1) supports a
configurable maxComponents ceiling, (2) supports tiered per-file-type limits
via flat-config layering, and (3) reuses this plugin's centralized component
detection so its behavior matches the sibling @wl rules. (eslint-plugin-react
is not a dependency of this repo.)
Option: maxComponents (optional)
The component ceiling before the rule reports. Defaults to 4. Only counts
strictly greater than the ceiling are reported (count === max is allowed).
{
rules: {
'@wl/max-components-per-file': ['error', { maxComponents: 2 }],
},
}Option: paths / ignore (optional)
Same glob semantics as the other rules: paths restricts the rule to matching
files, ignore skips matching files (and takes precedence over paths).
Flat-config note: options are replaced, not merged, across matching config blocks. If you override
maxComponentsfor a glob, re-declareignorein that same block or its exemptions are lost.
require-component-stories
Require a co-located Storybook story for every dumb component. By default it enforces two layouts,
mirroring the white-label fe-architect convention:
| Layout | Component file | Required story |
| ------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------- |
| Feature component | features/<feature>/components/<name>-component.tsx | features/<feature>/__stories__/<name>-component.stories.tsx (feature root) |
| Shared default component | components/default/<name>-component.tsx | components/default/__stories__/<name>-component.stories.tsx (sibling) |
A file is treated as a dumb component when it ends with the -component suffix, has a .tsx/.jsx
extension, sits directly inside a components/ directory (feature layout) or components/default/
(shared layout), and — by default — actually declares a React component. Containers, nested
components/sub/* files, barrels (index.*), and type files are never required to have stories. The
story is satisfied when any candidate (.tsx, .jsx, .ts, .js) exists on disk.
{
rules: {
'@wl/require-component-stories': 'error',
},
}Options (all optional)
| Option | Default | Description |
| --------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------- |
| paths | [] | Restrict the rule to files matching these globs. |
| ignore | [] | Skip files matching these globs (takes precedence over paths). |
| storiesDir | '__stories__' | Directory name a story must live in. |
| storySuffix | '.stories' | Suffix inserted before the extension. |
| storyExtensions | ['.tsx', '.jsx', '.ts', '.js'] | Accepted story extensions, in priority order. |
| componentSuffix | '-component' | Basename suffix a component must end with ('' disables the check). |
| requireComponentAst | true | When true, only require a story for files that actually declare a component. |
| extraTargets | [] | Extra structured layouts: { componentsDir, anchorParentDir?, storyMode: 'feature-root' \| 'sibling' }. |
{
rules: {
'@wl/require-component-stories': ['error', {
extraTargets: [{ componentsDir: 'widgets', anchorParentDir: 'ui', storyMode: 'sibling' }],
}],
},
}Caveats
- Filesystem-coupled. Unlike pure AST rules, this one checks the disk for a sibling story file, so results depend on the working-tree state.
--cache. Adding or removing a story file does not change the component file, so a cached ESLint result can go stale. Run without--cachein CI (or invalidate the cache) if you rely on this rule as a gate.- Case sensitivity. The existence check is exact-case; a casing mismatch may pass on a case-insensitive filesystem (macOS) and fail on a case-sensitive one (Linux CI).
- Unanchored globs.
paths/ignorepatterns match anywhere in the path, so anchor them (e.g. start with**/) when you need precision.
package-structure
Allowlist the layers under a package's src/, and the segments inside them, per package
type. A file that sits in a directory the lists do not name is reported once, at the top of the
file — every file under the offending directory carries the same message, which is how a "red
folder" surfaces in the IDE explorer. The message names the skill that owns that layout, so the fix
has an owner:
`core` is not an allowed `src/` layer for package type `frontend` (allowed: app, features, lib, components, pages, routes, __tests__). See /infra-kit:fe-architect for the frontend layout.
`hooks` is not an allowed segment of `features/user` for package type `frontend` (allowed: containers, components, services, __stories__, __tests__). See /infra-kit:fe-architect for the frontend layout.Two levels are checked:
- Layers — the first directory under
src/must be inlayers. - Segments — inside a layer that has a
segmentskey, the next directory must be in that list.features/*means "inside every folder offeatures/" (each folder is a unit of any name, e.g. a feature or a service);services(no/*) would mean "directly insideservices/". A layer without a key is not inspected below its first level.
Never reported: files — in src/ itself, in a layer, in a unit (features/user/index.ts) — and
anything deeper than a segment (features/user/components/default/…). A file not under
<package>/src/ at all is silent, so a shipped dist/src/** is too. A __tests__ or
__stories__ folder is an ordinary name: allowed exactly where a list says so.
{
rules: {
'@wl/package-structure': 'error',
},
}How the package type is resolved
The package root is the nearest ancestor of the linted file holding an infra-kit.config.ts or a
package.json. Its type is the type literal declared in infra-kit.config.ts when there is one
(type: 'backend'); otherwise the same inference the infra-kit CLI uses — apps/<app>/ui →
frontend, apps/<app>/api → backend, apps/<app>/tests → e2e, a mobile-app directory →
mobile, then dependency signals (@playwright/test → e2e, @capacitor/* → mobile,
serverless → backend), falling back to lib.
The config file is read as text — it is never imported or executed — and re-read when its mtime
changes, so an edit is picked up by the next lint of any file in that package, in CI and in the IDE
language server alike. A computed type (type: pickType()) is not a literal and falls back to
inference.
Options
One entry per package type. An absent key uses the built-in default below; a present key
replaces the whole entry (layers, segments and skill) — there is no merging. A type with no
entry is silent, which is why mobile and lib report nothing by default: their layouts are not
settled.
| Type | Default layers | Default segments | Default skill |
| ---------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------- |
| frontend | app, features, lib, components, pages, routes, __tests__ | features/* → containers, components, services, __stories__, __tests__ | /infra-kit:fe-architect |
| backend | controllers, services, lib, config, __tests__ | services/* → __tests__ | /infra-kit:be-architect |
| e2e | tests, visual, setup, pages, config, lib, components, mocks, fixtures | tests/*, visual/* → pages, fixtures, mocks, data, lib | /infra-kit:e2e-architect |
| mobile | — (silent) | — | — |
| lib | — (silent) | — | — |
| Option | Type | Description |
| ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| <type>.layers | string[] | Allowed first-level src/ directories for that type. |
| <type>.segments | Record<string, string[]> | Allowed directories one level down, keyed by <layer>/* (inside each folder of the layer) or <layer>. A key must name a layer from layers. |
| <type>.skill | string | Appended as "See <skill> for the <type> layout."; '' or absent drops the sentence. |
| ignore | string[] | Skip files matching these globs. |
// Override one type wholesale; the other types keep their defaults.
export default config({
rules: {
'@wl/package-structure': [
'error',
{
backend: {
layers: ['lambda_controllers', 'services', 'libs', 'local'],
segments: { 'services/*': ['__tests__', 'fixtures'] },
skill: '/infra-kit:be-architect',
},
},
],
},
})Flat config replaces rule options across matching blocks (it does not merge), so a consumer override restates every type it wants to change — and only those.
Caveats
- Filesystem-coupled. The rule stats the package's
infra-kit.config.tsonce per linted file (microseconds) and reads it only when the mtime changed.package.jsondependency signals are read once per lint process; adding@playwright/testmid-session is apnpm installevent, not an edit-lint loop, and is seen by the next process. --cache. Renaming a directory changes every file path under it, so ESLint's cache already misses; editinginfra-kit.config.tsdoes not touch the linted files, so a cached result can go stale until those files change. Run without--cachein CI if you rely on this rule as a gate.- The backend skill is a forward reference.
/infra-kit:be-architectis named in the default message ahead of the skill shipping; overridebackend.skillif the pointer should go elsewhere.
test-location
Require unit tests to be *.test.* files in a __tests__/ folder beside the code they test,
never next to the source file. The file is reported at the top, with the path to move it to:
`slugs.test.ts` sits next to its source. Move it to `src/lib/__tests__/slugs.test.ts` — tests live in a `__tests__/` folder beside the code they test.Inside a frontend feature (src/…/features/<name>/) the only place is the feature root's
__tests__/ — the /infra-kit:fe-architect layout — so features/<name>/services/__tests__/ is
reported too, with features/<name>/__tests__/ as the target. Elsewhere any __tests__ segment in
the path satisfies the rule, so nested folders (__tests__/fixtures/…) are fine.
.spec is reserved for Playwright: a *.spec.* file is reported with its *.test.* name. Files in
an e2e package (by the same package-type resolution package-structure uses) are never reported
— e2e-file-layout owns their layout.
{
rules: {
'@wl/test-location': ['error', { ignore: ['**/*.e2e.test.*'] }],
},
}Options
| Option | Type | Default | Description |
| -------- | ---------- | ------- | ---------------------------------------------- |
| ignore | string[] | [] | Globs; the rule is skipped for matching files. |
The recommended preset enables it on test-file names only, with ignore: ['**/*.e2e.test.*'] for
e2e suites that sit in a package of another type.
e2e-file-layout
Hold an e2e package to its file layout: specs are *.spec.ts at the root of a domain folder
(src/tests/<domain>/ or src/visual/<domain>/), and every other file in a domain folder sits in
one of its subfolders — pages/, fixtures/, mocks/, data/, lib/. The file is reported once,
at the top, with where it belongs:
`edge-redirects.test.ts` uses `.test`. Rename it to `edge-redirects.spec.ts` — every Playwright spec in an e2e package is `*.spec.ts`.
`src/tests/ui/auth/login.spec.ts` nests a spec below its domain folder. Move it to `src/tests/ui/login.spec.ts`, or split the domain into two sibling domains if it holds too many specs.
`checkout.page.ts` sits in the root of domain `checkout`, which holds only `*.spec.ts`. Move it to `src/tests/checkout/pages/checkout.page.ts`.The subfolder comes from the suffix: .page / .component → pages/, .fixture → fixtures/,
.mock → mocks/, .data → data/, anything else → lib/. Files outside src/tests and
src/visual (shared src/pages/, src/setup/*.setup.ts, playwright.config.ts) are not judged
beyond the spec checks; which src/ folders may exist at all is
package-structure's job. Packages of any other type are silent — the type is
resolved the same way package-structure resolves it.
{
rules: {
'@wl/e2e-file-layout': 'error',
},
}Options
| Option | Type | Default | Description |
| -------- | ---------- | ------- | ---------------------------------------------- |
| ignore | string[] | [] | Globs; the rule is skipped for matching files. |
e2e-file-order
Open every e2e source file with its main content, so a reader sees it right after the imports:
*.page.ts / *.component.ts open with the class, *.fixture.ts with the fixtures type and the
export const test = base.extend<…>(…) call, *.spec.ts with its first test.* call
(test.describe, test.use, test.describe.configure, …), *.mock.ts with its first exported
function. Constants, helpers and types declared between the imports and that anchor are reported by
name. A file under a lib/ folder opens with its exports: a private declaration above any export
(an export { … } list counts) is reported the same way:
`CART_URL` sits between the imports and class `CartPage`. Move it below class `CartPage`: a page object file opens with its imports, then the class. The autofix moves it to the end of the file.A const is not hoisted, so a binding read while the module loads stays above the anchor and is
not reported: a describe title, test.use({…}), a loop or forEach inside a describe body, a
static class field, an extend option default like [DEFAULT_LOCALE, { option: true }] — and
anything a kept binding reads. Reads inside test(…) bodies, hooks, fixture functions, methods and
instance fields run later and do not pin a binding. --fix moves the reported statements, with
their attached comments and in their original order, to the end of the file; a comment separated
from the statement by a blank line (a file overview) stays where it is. *.data.ts files and
packages of any other type are not judged.
{
rules: {
'@wl/e2e-file-order': 'error',
},
}Options
| Option | Type | Default | Description |
| -------- | ---------- | ------- | ---------------------------------------------- |
| ignore | string[] | [] | Globs; the rule is skipped for matching files. |
e2e-page-object-member-order
Order the members of an e2e page object class (*.page.ts, *.component.ts): fields, the
constructor, getters, public methods, then private methods. A private or protected getter counts as a
private method. A property is a field even when it holds an arrow function, since properties
initialize in source order. Each member out of place is reported with the one it follows:
The public method `goto` comes after the private method `waitForPageList` in class `Catalog2Page`. Order a page object: fields, constructor, getters, public methods, private methods.--fix reorders the class body in one pass. The sort is stable, so members of one group keep their
relative order, and each member moves with its attached comments.
{
rules: {
'@wl/e2e-page-object-member-order': 'error',
},
}Options
| Option | Type | Default | Description |
| -------- | ---------- | ------- | ---------------------------------------------- |
| ignore | string[] | [] | Globs; the rule is skipped for matching files. |
e2e-describe-order
Order the body of every test.describe in an e2e spec: setup first (test.use,
test.describe.configure, test.setTimeout, test.slow, and test.skip / fixme / fail used
as a condition, not as a test), then hooks beforeAll → beforeEach → afterEach → afterAll,
then tests and nested describes. Statements that are not test.* calls are ignored.
`test.use` (setup) comes after `test('shows the total')` (test) in describe 'Cart'. Order a describe body: `test.use` / `test.describe.configure` / annotations, then hooks beforeAll → beforeEach → afterEach → afterAll, then tests and nested describes.There is no autofix: moving test.use({ … }) up past a const it reads would put the read in the
const's TDZ.
{
rules: {
'@wl/e2e-describe-order': 'error',
},
}Options
| Option | Type | Default | Description |
| -------- | ---------- | ------- | ---------------------------------------------- |
| ignore | string[] | [] | Globs; the rule is skipped for matching files. |
e2e-top-level-describe
Hold an e2e spec to one top-level test.describe, with its setup, hooks and tests inside it:
`test.describe('Cart Mutations')` is top-level describe number 2 in this spec; the limit is 1. Move it to its own spec file, or nest it inside `test.describe('Cart')`.
`test.use` sits at the top level of the spec. Move it inside `test.describe('Cart')`: a spec holds one top-level describe, and its setup, hooks and tests live inside it.
Test 'shows the total' sits at the top level of the spec. Wrap the spec’s tests in a `test.describe` named after the feature they cover.When the spec has exactly one top-level describe, --fix moves every top-level test.use,
test.describe.configure, hook and annotation to the start of its body, in source order and with
their attached comments; with one describe they apply to the same tests either way. An extra
describe or a top-level test needs a person: split the file, nest the describe, or wrap the tests.
{
rules: {
'@wl/e2e-top-level-describe': ['error', { max: 1 }],
},
}Options
| Option | Type | Default | Description |
| -------- | ---------- | ------- | ------------------------------------------------- |
| max | integer | 1 | Top-level test.describe blocks a spec may hold. |
| ignore | string[] | [] | Globs; the rule is skipped for matching files. |
e2e-test-tags
Restrict the tags on Playwright test(…) and test.describe(…) calls in an e2e package to an
allowed set — by default @smoke, @readonly, @mocked, @slow. Each unknown tag is reported on
its own string:
Tag `@mocking` is not an allowed e2e tag. Use one of `@smoke`, `@readonly`, `@mocked`, `@slow`; browsers, devices and environments are Playwright projects, not tags.Only string literals in the tag property of the details object are judged ({ tag: '@smoke' },
{ tag: ['@smoke', '@readonly'] }); a tag held in a shared constant is not resolved. test.step
and hooks carry no tags and are ignored.
{
rules: {
'@wl/e2e-test-tags': ['error', { allowed: ['@smoke', '@readonly', '@mocked', '@slow', '@mobile'] }],
},
}Options
| Option | Type | Default | Description |
| --------- | ---------- | --------------------------------------------- | ------------------------------------------------ |
| allowed | string[] | ['@smoke', '@readonly', '@mocked', '@slow'] | The only tags allowed. Each must start with @. |
e2e-test-title
Flag e2e test titles that open with should, and suggest the direct form — the title states the
behaviour and its observable outcome:
Test title "should reject an expired card" starts with `should`. State the behaviour and its observable outcome directly: "reject an expired card".Only test(…) and its only / skip / fixme / fail forms with a plain-string title are
checked; describe and test.step titles are not. The recommended preset enables it as error:
{
rules: {
'@wl/e2e-test-title': 'error',
},
}max-jsdoc-lines
Cap the height of a JSDoc block. The block's prose and its @example bodies
get two independent budgets, so a long worked example never makes the
description look bloated — and never eats the room a description needs.
This rule caps the whole block at 15 lines.
max-jsdoc-summary-lines caps the first paragraph
at 5. The two compose rather than overlap — a 40-line block whose summary is
two lines is a well-shaped long block, and a 7-line block that is one unbroken
paragraph is a badly-shaped short one. Do not unify them: this number is about
height, the other is about whether a reader can skim.
// ❌ Incorrect — 8 lines of contract wrapped around a 35-line `@example`
/**
* Extracts the first argument type from a WritableAtom's write function.
*
* @example
* … 33 more lines walking through a whole feature …
*/
export type ExtractAtomActionArgs<T> = …
// ✅ Correct — the smallest call that teaches usage; the walkthrough moves to a test
/**
* Extracts the first argument type from a WritableAtom's write function.
*
* @example
* type Action = ExtractAtomActionArgs<typeof counterAtom>
* // → { type: 'increment' } | { type: 'set'; value: number }
*/
export type ExtractAtomActionArgs<T> = …Every JSDoc-shaped block comment is measured, not just the ones attached to a
function. The rule iterates the file's comments and treats
comment.type === 'Block' && comment.value.startsWith('*') as JSDoc — so blocks
above a type, an interface, a bare const, or a statement inside a function
body are all covered. That is deliberate: a rule driven by node types needs an
anchor allowlist, which is exactly how jsdoc/match-description ends up not
covering type and interface declarations. /* eslint-disable … */ is not a
JSDoc block (its text starts with a space, not *) and is never measured.
Counting rules:
- Total is
end.line - start.line + 1— the/**and closing lines included. @examplebodies run from each@exampleline (inclusive) to the next line that opens a tag; an@examplethat is the block's last tag therefore runs through the closing line. Multiple@examplebodies sum.- Prose is the remainder (
total - example), so the two budgets partition the block exactly.@param/@returns/@throwslines charge the prose budget. - Both budgets can report on the same block — they are checked independently.
Why two budgets. @wl/require-jsdoc-example mandates an @example on
functions at cognitive complexity ≥ 12. If a rule-mandated example were charged
against the prose budget, those functions could be pushed into a state with no
compliant answer. Charging examples to their own budget is the contract that
keeps the two rules from deadlocking; it is pinned by an integration test.
Options
| Option | Default | Description |
| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------- |
| maxLines | 15 | Prose ceiling — total lines minus the @example bodies. |
| maxExampleLines | 10 | Combined ceiling for the block's @example bodies. |
| exemptTags | ['fileoverview', 'module', 'packageDocumentation'] | Tags that exempt a block entirely. |
Only counts strictly greater than a ceiling report (lines === max is allowed).
{
rules: {
'@wl/max-jsdoc-lines': ['warn', { maxLines: 20, maxExampleLines: 12 }],
},
}The @fileoverview escape hatch
File-level architectural rationale is the documentation most worth keeping, and
it is naturally long. Tag such a block with @fileoverview (or @module /
@packageDocumentation) and the rule skips it entirely — any tag in
exemptTags exempts the whole block, both budgets.
/**
* @fileoverview Why this module exists, and the three constraints it balances.
*
* … 50 more lines of rationale, deliberately kept …
*/There is no positional exemption — being the first block in the file exempts
nothing. Position exempts by luck: a file-level block whose next statement is
let readers = 0 rather than an import would be missed by any "precedes the
first import" spelling, while a 30-line block documenting the first exported
class would be exempted for no reason at all. A declared tag is greppable,
reviewable, and cannot silently widen. The tooManyLines message names the tag
inline so the fix is visible at the point of failure.
A trailing block at the end of a file, with no token after it, is skipped as degenerate input (an EOF guard — the rule never resolves the symbol a block documents).
Not fixable, deliberately
There is no autofix, and there will not be one: shortening a block means
deleting sentences, and the longest blocks in a codebase are usually its most
valuable documentation. The part of this problem that is mechanically fixable —
blank-line padding and {type} annotations — is already handled by
jsdoc/tag-lines and jsdoc/no-types, which do have fixers. Run those first;
what remains is a judgement call for a human.
Caveat
The @example scan ends a body at any line starting with @, so a decorator
(@Injectable) written inside an @example would truncate the body early and
under-count it. A scan of every @example in the codebase found no such line, so
this is correct today — but it is a measured constraint, not a guarantee.
max-jsdoc-summary-lines
Cap the height of a JSDoc block's summary paragraph — the first thing a reader sees. The requirement this encodes is that the essence of a comment should be graspable in 3 to 5 lines; the default ceiling is the loose end of that range.
// ❌ Incorrect — six unbroken lines before the reader learns anything they can act on
/**
* Resolves the dev proxy for a package by walking the preset table, falling back
* to the launched backend set, then to the cloud gateway, honouring an explicit
* pin where one exists, and finally writing the chosen source into the dev
* context fragment so the vite plugin can re-resolve it without a restart, which
* matters because the plugin watches that directory rather than the config file
* and would otherwise bake the proxy at config load.
*/
export const resolveProxy = () => {}
// ✅ Correct — one glance, a blank line, then the detail
/**
* Resolves the dev proxy for a package: preset pin, then launched backends, then
* the cloud gateway.
*
* The chosen source is written to the dev-context fragment rather than returned,
* because the vite plugin watches that directory and would otherwise bake the
* proxy at config load.
*/
export const resolveProxy = () => {}Composes with max-jsdoc-lines, does not overlap it. That
rule caps the whole block at 15 lines; this one caps the first paragraph
at 5. A block can satisfy either and fail the other, and both are worth having:
one is about height, the other about whether the block can be skimmed. Do not
unify them into a single budget.
Counting rules:
- The summary runs from the block's first prose line to the first blank line
or the first
@tagline, whichever comes first. - Leading gutter-only lines are skipped, so the paragraph starts wherever the prose does.
- Delimiter lines are not counted. The
/**opener and the closing line carry no prose. This is the one place the count differs frommax-jsdoc-lines, which measures visual height and so counts them. - A block with no blank line and no tags is one paragraph end to end, so its whole prose body is the summary.
- A block that opens with a tag has a zero-line summary and can never report.
@param-only blocks are a contract, not a description. - Only counts strictly greater than the ceiling report (
lines === maxis allowed).
Options
| Option | Default | Description |
| ----------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------- |
| maxSummaryLines | 5 | Prose ceiling for the summary paragraph. |
| exemptTags | ['fileoverview', 'module', 'packageDocumentation'] | Tags that exempt a block entirely. Same list as max-jsdoc-lines, so one tag does both. |
{
rules: {
'@wl/max-jsdoc-summary-lines': ['warn', { maxSummaryLines: 3 }],
},
}Calibration
51 of the 2,171 JSDoc blocks in apps/infra-kit/cli/src exceed 5 summary lines
(measured 2026-09-05; docs/comment-review-skill-plan.md §9). That is 2.3% of
linted blocks — the same order as max-jsdoc-lines's 2.12%, which is to say a
normal lint-rule yield rather than a codebase-wide sweep.
Not fixable, deliberately
There is no autofix. The mechanical fix — insert a blank line after line 5 —
splits a paragraph at an arbitrary point and produces a summary that reads as
truncated. Deciding what belongs in the first glance is judgement, and the same
reasoning that keeps max-jsdoc-lines unfixable applies here.
Module-level rationale is exempted the same way: tag the block @fileoverview
(or @module / @packageDocumentation). There is no positional exemption, and
the message names the tag inline so the escape hatch is visible at the point of
failure.
require-jsdoc-example
Graduated JSDoc requirement driven by cognitive complexity: at or above minComplexity a named
top-level function must carry a leading JSDoc block, and at or above exampleComplexity that block
must also include an @example tag. Functions below the first threshold are left alone — the rule
asks for documentation where a reader needs it, not everywhere.
// ❌ Incorrect — complexity 13, no JSDoc at all
export const resolveRoute = (ctx: Ctx) => { /* nested branches, loops, early returns … */ }
// ✅ Correct — a block, and because 13 ≥ 12, a worked `@example`
/**
* Pick the route for a request, honouring locale, redirects and the fallback page.
*
* @example
* resolveRoute({ path: '/he/deals', locale: 'he' }) // → { page: 'deals', locale: 'he' }
*/
export const resolveRoute = (ctx: Ctx) => { … }Targets are named function declarations and const name = () => … / function expressions at the
top level, with or without an export wrapper; the JSDoc may sit on the declaration or on the
export statement. Complexity is the same cognitive-complexity metric sonarjs uses. The
recommended preset ships this rule at warn in its own **/*.{ts,tsx} block, because the functions
it targets overwhelmingly live in plain .ts modules.
Options (all optional)
| Option | Default | Description |
| ------------------- | ------- | ----------------------------------------------------------------------------------- |
| minComplexity | 8 | Cognitive complexity at or above which a function must carry a leading JSDoc block. |
| exampleComplexity | 12 | Cognitive complexity at or above which that block must also include an @example. |
| paths | [] | Restrict the rule to files matching these globs. |
| ignore | [] | Skip files matching these globs (takes precedence over paths). |
The @example bodies this rule mandates are charged to their own budget by
max-jsdoc-lines, so satisfying one rule can never violate the other.
