Skip to content
BoringStack
GitHub

Lint as the contract

8 min read

Lint contract

This is the load-bearing piece of Why BoringStack: architecture lives in tooling, with prose for context. The docs explain intent; lint makes drift visible before review.

validate

merge gate

ESLint

machine-readable contract

agents

same feedback as humans

Architecture docs rot. New teammates miss local conventions. Agents can generate plausible code in the wrong layer. Reviewers should spend their time on product behavior, not rediscovering that a route now contains business logic or a component reads environment state directly.

BoringStack treats lint as the executable part of the architecture. AGENTS.md, CLAUDE.md, and AGENT_CONTRACT.md explain intent; custom ESLint plugins make the important parts fail the build.

Every template ships prose contracts and lint gates together. The prose says where routes go, when to use a service, how to write a Drizzle query, why process.env is forbidden outside the env validator. The plugins turn those patterns into parseable, repeatable errors.

bun run validate / bun run validate is the merge gate: if lint fails, the diff does not ship. Prose is the why; lint is what ships.

layers

Layer boundaries

Routes stay thin, services own business logic, components render view objects, and feature folders keep one semantic concern per file.

data

Data safety

Account-scoped queries require filters, multi-write paths require transactions, cache keys are namespaced, and Stripe webhooks are idempotent.

security

Security invariants

JWT cookies, OAuth state/PKCE, raw webhook bodies, env access, and secret-safe logging are checked mechanically.

ops

Operational hygiene

Structured log events, test placement, dead-code detection, env/schema drift checks, and no inline disables keep the merge gate honest.

agents

Agent-friendly feedback

Violations point at the file, rule, and local fix. Humans and agents get the same contract.

review

Review focuses on behavior

Reviewers spend energy on behavior instead of rediscovering folder and boundary rules enforced by ESLint.

Use this as the reference catalog after the mental model makes sense.

resource-architecture

@boring-stack-pkg/eslint-plugin-resource-architecture: per-feature route/service/types split. No business logic in routes; no HTTP in services.

module-boundaries

@boring-stack-pkg/eslint-plugin-module-boundaries: single-semantic-module files. No mixed-concern dumps.

structured-logging

@boring-stack-pkg/eslint-plugin-structured-logging: Pino discipline. No console.log, no string-interpolated log lines, no logging secrets. The typed-event-names rule validates every logger. {level}({event}) literal against the canonical LOG_EVENTS const tuple so log events stay a closed set.

env-access

@boring-stack-pkg/eslint-plugin-env-access: process.env / import.meta.env only inside the env validator; everywhere else uses validated config.

test-conventions

@boring-stack-pkg/eslint-plugin-test-conventions: tests/ mirrors src/; no orphan tests.

code-flow

@boring-stack-pkg/eslint-plugin-code-flow: control-flow discipline. prefer-early-return (no needless nesting after a guard) and no-template-trim-empty-ternary (bans the inline ${a} ${b} .trim() === "" ? fallback : …anti-pattern: extract to a named util like buildDisplayName(…)so the construction is testable in one place); also enforces blank-line padding beforethrowandreturn` so the exit branch is visually distinct.

comment-hygiene

@boring-stack-pkg/eslint-plugin-comment-hygiene: no-narration-comments flags AI-generated narration like “Here we…” / “Now we…” / “Let’s…”; no-pr-reference-comments flags #123 / PR 42 / GitHub URLs embedded in code (they belong in the PR description, not the source).

Built-in + third-party rules wired at the same gate

Section titled “Built-in + third-party rules wired at the same gate”

Two non-custom rules earn their place alongside the @boring-stack-pkg plugins:

eslint-comments/no-use

@eslint-community/eslint-plugin-eslint-comments: no-use is set to error with { allow: [] }. Zero inline disables; defence-in-depth on top of the source-text ban that lint:meta already enforces.

multiline-comment-style: starred-block

Built-in ESLint rule. Any //-style block that spans three or more consecutive lines must be a single /* … */ block. Auto-fixable; keeps WHY-comments visually distinct from inline notes.

no-restricted-syntax: ban inline new Date().toISOString()

Built-in ESLint rule with a project-specific selector. Every call site must use the now() util from src/lib/time/now.ts so timestamps have a single mockable source and a single place to change formatting if the contract ever shifts.

elysia

@boring-stack-pkg/eslint-plugin-elysia: TypeBox on every route, no untyped handlers, plugin registration patterns. route-must-check-ability requires every handler that destructures membership from context to authorize explicitly: either read membership.role or call requireAbility / enforceLimit.

drizzle-conventions

@boring-stack-pkg/eslint-plugin-drizzle-conventions: schema and query patterns, index naming, with usage. account-scoped-tables-require-where requires every query against an account-scoped table (widgets, accountMemberships, accountInvitations, accountFeatureOverrides, accountPlans) to filter by accountId (tenant isolation enforced at the query level).

db-transactions

@boring-stack-pkg/eslint-plugin-db-transactions: multi-write paths inside a transaction.

jwt-cookies

@boring-stack-pkg/eslint-plugin-jwt-cookies: cookie attributes and JWT verify call sites.

oauth-security

@boring-stack-pkg/eslint-plugin-oauth-security: OAuth state + PKCE on the server flow.

bullmq

@boring-stack-pkg/eslint-plugin-bullmq: queue/worker idempotency, failed handlers, name discipline.

cache-keys

@boring-stack-pkg/eslint-plugin-cache-keys: namespaced cache keys and TTL discipline.

audit-log

@boring-stack-pkg/eslint-plugin-audit-log: audit writes on flagged mutations.

stripe-webhooks

@boring-stack-pkg/eslint-plugin-stripe-webhooks: signature verification, idempotency, raw-body access.

react-component-architecture

@boring-stack-pkg/eslint-plugin-react-component-architecture: component anatomy, hooks, className discipline, file naming, prop ordering. The max-hooks-per-file rule caps top-level hooks per file (default 4) so god-modules like a 7-hook *.queries.ts either get split or fail the gate.

tanstack-query-cache

@boring-stack-pkg/eslint-plugin-tanstack-query-cache: on *.queries.ts, when keys are built as [...PREFIX, …], cache writes must use matcher-style APIs (setQueriesData, cancelQueries with exact: false / predicate, etc.) instead of setQueryData / getQueryData on the bare prefix alone; otherwise only one entry updates while hooks still read the spread key.

i18n-keys

@boring-stack-pkg/eslint-plugin-i18n-keys: static string keys in t("…") / i18n.t("…") must exist in the canonical English catalog (src/lib/i18n/locales/en/common.json in the template). Catches typos and orphan keys at lint time.

Each template’s flat ESLint config (eslint.config.mjs in apps/ui, eslint.config.js in apps/api) imports these plugins from node_modules/. They are devDependencies installed from npm as exact-version @boring-stack-pkg/eslint-plugin-* pins; source lives in the boringstack-xyz/eslint-plugins monorepo, releases run through Changesets, and every published version carries an OIDC-signed provenance attestation. The 7-day minimumReleaseAge quarantine that protects the rest of the dependency tree is exempted for the @boring-stack-pkg/* scope (it’s our own code; the quarantine guards against compromised upstream publishes we don’t control). The plugins ship their own recommended configs where applicable; we extend those and keep severities at error so the merge gate has real teeth.

Where lint:meta is wired (bun run lint:meta / bun run lint:meta), it adds extra contract checks on top of ESLint — package.json pin parity, CI/pre-push parity, env cascade drift, source-text bans, and test-sibling requirements. The full machine-readable catalog lives on lint:meta rules; implementation is under scripts/lint-meta/cli.ts with fixture-based tests in tests/lint-meta/. For which script runs each merge-gate command, see Scripts & tooling.

Merge gate
$ bun run validate
$ bun run lint
$ bun run lint:fix
$ bun run knip

#   validate: typecheck + lint + knip + tests
#   lint: architecture rules only
#   lint:fix: autofix what can be fixed mechanically
#   knip: unused files, exports, and dependencies

The same merge gate runs in three places so a broken change can’t slip through the cracks. A husky pre-commit hook runs bun run lint-staged against the staged files, the cheap, scoped check on every commit. A husky pre-push hook (installed via prepare on bun run install / bun install; for infra/compose and .github, run ./scripts/install-hooks.sh once) then runs the full validate locally before anything leaves your machine: typecheck, lint, knip, tests, build, osv-scanner against the lockfile, and (when workspace apps are present) an OpenAPI schema drift check across apps/api ↔ apps/ui. CI re-runs the same gate so a --no-verify push is still caught.

Knip sits alongside the lint plugins, surfacing unused files, unused exports, and unused dependencies. It runs as part of validate so dead code can’t drift in unnoticed.

Why this matters for agent-driven development

Section titled “Why this matters for agent-driven development”

Lint turns architecture into a closed loop: code is written, violations surface as parseable errors, the fix is specific (wrong layer, missing schema, env read outside the validator). Review time stays on product logic instead of rediscovering folder rules.

For humans and agents alike, docs explain and lint enforces. Both matter; lint is what keeps the merge gate honest.

The plugins are MIT-licensed and developed in the open. If you find a gap, file an issue or PR against the relevant plugin repo (linked above). Rules that prove themselves in production end up promoted to recommended configs.