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

@slip-stream-kit/eslint-plugin

v0.16.0

Published

Custom ESLint rules enforcing the white-label frontend architecture conventions

Readme

@wl/eslint-plugin

Custom ESLint rules that enforce the white-label frontend architecture conventions.

Installation

pnpm add -D @wl/eslint-plugin

Usage (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 a JSXElement), so it is not counted — extracting strictly reduces the number.
  • Fragments are free. <>…</> contributes 0; 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 />} /> counts Foo and Icon.

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 file

Only 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 maxComponents for a glob, re-declare ignore in 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 --cache in 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/ignore patterns 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 in layers.
  • Segments — inside a layer that has a segments key, the next directory must be in that list. features/* means "inside every folder of features/" (each folder is a unit of any name, e.g. a feature or a service); services (no /*) would mean "directly inside services/". 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.ts once per linted file (microseconds) and reads it only when the mtime changed. package.json dependency signals are read once per lint process; adding @playwright/test mid-session is a pnpm install event, 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; editing infra-kit.config.ts does not touch the linted files, so a cached result can go stale until those files change. Run without --cache in CI if you rely on this rule as a gate.
  • The backend skill is a forward reference. /infra-kit:be-architect is named in the default message ahead of the skill shipping; override backend.skill if 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.
  • @example bodies run from each @example line (inclusive) to the next line that opens a tag; an @example that is the block's last tag therefore runs through the closing line. Multiple @example bodies sum.
  • Prose is the remainder (total - example), so the two budgets partition the block exactly. @param/@returns/@throws lines 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 @tag line, 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 from max-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 === max is 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.