Skip to content
BoringStack
Star

Lint as the contract

8 min read

Architecture lives in tooling, with prose for context. The docs explain intent; lint makes drift visible before review.

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.

  • Layer boundaries. Routes stay thin, services own business logic, components render view objects, and feature folders keep one semantic concern per file.
  • Data safety. Account-scoped queries require filters, multi-write paths require transactions, cache keys are namespaced, and Stripe webhooks are idempotent.
  • Security invariants. JWT cookies, OAuth state/PKCE, raw webhook bodies, env access, and secret-safe logging are checked mechanically.
  • Operational hygiene. Structured log events, test placement, dead-code detection, env/schema drift checks, and no inline disables keep the merge gate honest.
  • Agent-friendly feedback. Violations point at the file, rule, and local fix. Humans and agents get the same contract.
  • 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 once the overview above makes sense.

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.

  • 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 (same contract in apps/api and apps/ui):

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

The same merge gate runs in three places so a broken change cannot reach main. 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 and 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.