Enforcing Beach's Architectural Rules with ESLint
Beach asks adopting applications to honour a small set of architectural commitments — the orchestrator stays channel-blind, prompts stay surface-agnostic, renderer bypasses carry an explicit escalation. The commitments are documented in Reference: design principles, and the Anti-patterns page describes what going wrong looks like. Without mechanical enforcement, the rules drift over time as features ship and reviewers turn over. @cool-ai/eslint-plugin-beach is the mechanical guardrail: every rule corresponds to one of the commitments, and CI catches the violation before it reaches main.
This guide walks the wiring end-to-end and explains what each rule catches, with the worked examples that surface most often in practice.
Install
npm install --save-dev @cool-ai/eslint-plugin-beach eslint
ESLint v9 (flat config) is required. The plugin re-exports nothing and adds no peer dependencies beyond ESLint itself.
Wiring the recommended preset
The recommended preset enables all four rules at error severity:
// eslint.config.js
import beach from '@cool-ai/eslint-plugin-beach';
export default [
beach.configs.recommended,
];
That is the whole setup for an application that wants the canonical posture. Beach itself uses this same preset over its own source via the precommit and prepush gates.
Most applications also want @typescript-eslint/parser registered so the plugin operates on TypeScript syntax rather than failing on import type declarations:
import beach from '@cool-ai/eslint-plugin-beach';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['**/*.{ts,tsx,mts,cts}'],
languageOptions: { parser: tsParser, ecmaVersion: 2022, sourceType: 'module' },
plugins: { beach: beach },
rules: {
'beach/no-channel-mode-branching': 'error',
'beach/no-channel-types-in-non-adapter-packages': 'error',
'beach/no-geometry-tokens-in-prompts': 'error',
'beach/escalated-bypass-required': 'error',
},
},
];
The four rules
Each rule maps to one architectural commitment. The defaults are conservative — most applications need no per-rule configuration. The options are documented for the cases where the defaults need extending or narrowing.
beach/no-channel-mode-branching
The interior is channel-blind. An actor or handler that branches on session.channelMode (or on a destructured channelMode) is encoding channel awareness in the place the architecture forbids it. The rule reports any if, switch, or comparison that reads the field:
// ✗ violates the rule
if (session.channelMode === 'batched') { /* … */ }
switch (session.channelMode) { case 'streaming': /* … */ }
const { channelMode } = session;
if (channelMode === 'batched') { /* … */ }
// ✓ — express the variation in the wiring, not the interior
const collector = config.channelMode === 'batched'
? new BatchedChannelCollector({ /* … */ })
: null;
The rule has no options. Its scope is fixed: the field-name channelMode, the comparison operators (===, !==, ==, !=), and switch discriminants.
beach/no-channel-types-in-non-adapter-packages
Channel-typed transport packages (@cool-ai/beach-transport-whatsapp, @cool-ai/beach-transport-email, etc.) are allowed to be imported from adapter packages — the transport-*, channel-*, composer-*, format-*, and destination-* directories. Imports from anywhere else leak channel identity into the channel-blind interior:
// in packages/session/… — ✗ violates the rule
import { WhatsAppMessage } from '@cool-ai/beach-transport-whatsapp';
// in packages/transport-whatsapp/… — ✓ permitted
import { WhatsAppMessage } from '@cool-ai/beach-transport-whatsapp';
The rule infers adapter-vs-non-adapter from the file's path. Override the defaults via the rule's options when your repository uses different naming:
'beach/no-channel-types-in-non-adapter-packages': ['error', {
channelPackages: [
'@cool-ai/beach-transport-whatsapp',
'@cool-ai/beach-transport-email',
'@your-org/your-channel-package',
],
adapterPackagePatterns: [
'/packages/transport-',
'/packages/channel-',
'/apps/edge-',
],
}],
adapterPackagePatterns are matched as substrings against the full file path. A path containing any one of the patterns is treated as adapter code.
beach/no-geometry-tokens-in-prompts
The actor produces channel-blind, surface-agnostic content. A prompt that says "tell the user to tap the button to confirm" is encoding geometry — the actor has no way to know whether the user is reading the response on a phone, in an email, or via voice. Geometry belongs in composers, which see the channel; it does not belong in the prompt that drives the channel-blind actor.
The rule scans string properties whose key looks like a prompt (prompt, systemPrompt, instructions, persona, etc.) and flags occurrences of geometry tokens like "above the fold", "tap the button", "scroll down", "left column":
// ✗ violates — the actor must not direct surface geometry
const config = {
prompt: 'Tell the user to tap the button to confirm.',
};
// ✓ — surface-neutral instruction; the composer renders the call-to-action
const config = {
prompt: 'Confirm the booking with the user.',
};
The default token list is conservative. Extend it with project-specific terms via the geometryTokens option:
'beach/no-geometry-tokens-in-prompts': ['error', {
geometryTokens: [
'above the fold',
'left column',
'right column',
/* default list also stays in scope */
'in the lower-right widget', // your custom term
],
promptIdentifiers: [
'prompt',
'systemPrompt',
'instructions',
'persona',
'directive', // your project's prompt-shaped key name
],
}],
When the rule reports a violation, the fix is almost always to remove the geometric direction and let the composer decide. If a project genuinely needs to reference a region of the surface in the prompt — for example, a tutorial application that walks the user through the UI — narrow the rule's promptIdentifiers to exclude the specific keys carrying that prose, or downgrade the rule to 'warn' for those files.
beach/escalated-bypass-required
A renderer-bypass is the recognised escape hatch when a channel's surface-parity contract has to be broken — a WhatsApp template that requires a flat layout, an SMS composer that has to drop A2UI structure to fit 160 characters. The bypass is permitted, but every instance must declare escalated: true with a non-empty reason:
// ✗ — no escalation flag
const cfg = {
bypass: { reason: 'WhatsApp template constraint' },
};
// ✗ — escalated: false is not a valid bypass
const cfg = {
bypass: { escalated: false, reason: 'temporary workaround' },
};
// ✗ — escalated: true but reason is missing
const cfg = {
bypass: { escalated: true },
};
// ✓ — explicit escalation + non-empty reason
const cfg = {
bypass: {
escalated: true,
reason: 'WhatsApp template constraint forces flat layout for partner X',
},
};
The default property names the rule scans are bypass, rendererBypass, surfaceBypass, and parityBypass. Override via bypassPropertyNames if your project uses different keys.
The intent is structural visibility. A bypass that ships without an articulated reason indicates the surface-parity contract was waived without an explicit decision — the kind of thing that, six months later, no one remembers why it was done. The escalated: true declaration is a check that the decision was deliberate and the reason worth recording.
Wiring into your CI
Run ESLint as part of the same gate that runs typecheck and tests. A typical package.json:
{
"scripts": {
"build": "tsc --project tsconfig.json",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint --max-warnings=0 ."
}
}
Then npm run lint (or whatever package manager the project uses) becomes one of the steps the precommit / CI pipeline runs. --max-warnings=0 ensures unused eslint-disable directives also fail the gate; without it those directives accumulate as dead annotations.
When a violation reports
The rules are mechanical. They flag patterns, not intent. Two paths when a violation lands:
- The rule is right and the code is wrong. Remove the channel-mode branch, refactor the import out of the non-adapter package, or rewrite the prompt to be surface-neutral. The rule has caught the architectural drift the rule exists to catch.
- The rule is wrong for the specific case. Per-line suppression with
// eslint-disable-next-line beach/<rule-name>and a comment explaining why is the safety valve. Use it sparingly; the rules should fail far more often than they suppress.
If a class of cases is genuinely outside the rule's intended scope — a configuration file that names channel constants, a test fixture that simulates branching for the test itself — narrow the rule's options or scope it to specific paths via ESLint's files field rather than peppering the codebase with suppressions.
Why this exists
The architectural rules are the ones consumers absorb when they integrate Beach. Without mechanical enforcement, the rules drift — a channel-mode branch sneaks into the orchestrator under deadline pressure, a geometry token gets added to a prompt during a UI revision, a bypass ships without escalation because nobody on review thought to ask. The plugin is the cheap, mechanical, zero-discretion guard that catches these before they ship.
The rules are dogfooded against Beach's own source, so the plugin's own behaviour stays consistent with the architectural commitments it enforces. Adopting the plugin in a Beach application means the application gets the same defences Beach itself relies on.
Related
- Reference: design principles — the architectural commitments the rules enforce.
- Anti-patterns — the failure modes the rules guard against.
@cool-ai/eslint-plugin-beachREADME — rule-by-rule reference and option shapes.