---
title: "Tooling"
description: "Two lint plugins that turn the system's most important rules from advice into build failures. A rule enforced only by review is a rule enforced only sometimes."
url: "https://opsinjs.pensievelabs.org/handbook/tooling"
source: "https://opsinjs.pensievelabs.org/handbook/tooling.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["lint rules", "eslint plugin", "stylelint plugin", "enforcing the rules"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="handbook" />

## The short version [#the-short-version]

<NotBuiltYet />

Two packages are specified:

* [`eslint-plugin-opsinjs`](./eslint-plugin.mdx) covers JSX and TypeScript.
  Catches a raw colour, a mixed axis, a banned word, and a component used
  against its documented contract.
* [`stylelint-plugin-opsinjs`](./stylelint-plugin.mdx) covers CSS. Catches a
  hardcoded colour, a token used outside its tier, and a status token applied to
  a category surface.

The reasoning is simple arithmetic. The never-mix-the-axes rule has one page of
doctrine, one section on every component page, a paragraph in the agent skill
and a mention in the contributing checklist. That is five places asking a human
to remember. A lint rule asks nobody.

## How it works [#how-it-works]

The rules divide by what they can see.

**A lint rule can check a syntactic invariant.** Is this a hex colour in a
`className`? Are `status` and `category` both being passed to a component whose
documentation says they must not both change the surface? Is this CSS custom
property from the status namespace being assigned to a category role? These are
decidable from the source and are exactly what a plugin should own.

**A lint rule cannot check a semantic one.** Whether *this particular* reading
deserves `urgent` is a clinical judgement, and no static analysis will ever
answer it. The plugins deliberately do not try; they enforce the shape of the
system, and the meaning stays with the people who own the thresholds.

The third layer is runtime: development-mode warnings for the mistakes that only
become visible when data flows through them. See
[Error codes](../error-codes.mdx). Each layer catches what the one before it
cannot, and the error codes and lint rule names are deliberately aligned so that
one search finds both.

## Do this [#do-this]

* **Install both plugins and turn them on in CI.** A rule that runs only
  locally is a rule that runs only for the people who remember.
* **Start with the recommended config.** It contains the rules that are almost
  never a false positive; add the stricter ones once the codebase is clean.
* **Fix rather than disable.** If you must disable, disable the specific rule on
  the specific line with a comment saying why. A bare file-level disable removes
  protection nobody will notice is gone.
* **Report a false positive.** A noisy rule gets disabled wholesale, and then
  the real violations get through too.

## Not this [#not-this]

* **Do not treat the plugins as sufficient.** They enforce shape. Every
  component has been audited against WCAG 2.2 AA, but that audit was run by
  the authors, not an independent reviewer; see
  [ADR 0025](../../project/decisions/0025-the-audit-is-author-run.mdx) for the
  record. The independent accessibility review, the clinical review and the
  content review are still the things that catch the failures that matter
  most.
* **Do not add project rules to the `opsinjs/` namespace.** Use your own; the
  namespace is versioned.
* **Do not run the plugins on generated output.** `.source/`, `lib/generated/`
  and `public/r/` are excluded in this repository for exactly that reason.

## Gotchas [#gotchas]

* **The plugins do not exist yet.** Both pages are specifications. They carry
  proposed rule names so that the error codes, the documentation and the future
  implementation can be written against one vocabulary rather than three.
* **A lint rule cannot follow a variable.** A colour assembled at runtime, or a
  status read from a variable, is invisible to static analysis. That is what
  the runtime warnings are for.
* **Tailwind arbitrary values are the escape hatch that defeats most rules.**
  `bg-[--my-var]` and `bg-[#e11d48]` look similar and only one is a violation;
  the rule has to parse the arbitrary value rather than the class name.

## Related [#related]

* [ESLint plugin](./eslint-plugin.mdx) has the proposed JavaScript and JSX
  rules.
* [Stylelint plugin](./stylelint-plugin.mdx) has the proposed CSS rules.
* [Error codes](../error-codes.mdx) covers the runtime layer, and how the codes
  align with the rule names.
* [The two colour axes](../../health/two-colour-axes.mdx) is the invariant all
  of this exists to protect.
