Mechanical Enforcement
Why architectural decisions are enforced in code: ESLint rules, structural checks, and shell invariants
The central principle of tx-agent-kit is: if it is important, enforce it in code. Conventions that exist only in documentation are conventions that will be violated.
Three layers of enforcement
tx-agent-kit uses three complementary enforcement layers, each catching a different class of violations:
1. ESLint rules
ESLint catches import violations, banned APIs, and naming conventions at the file level. These rules run in the editor (instant feedback) and in CI (gate on merge).
The base preset is strictTypeChecked from typescript-eslint, which provides ~20 stricter rules beyond recommended (unnecessary conditions, exhaustive error typing, safer template expressions, etc.).
ESLint rules are split across config files in packages/tooling/eslint-config/:
base.js-strictTypeCheckedpreset and project-wide rule overridesdomain-invariants.js- Architecture and DDD layer boundary rulescode-quality.js- General code quality (unused imports, curly braces, modern JS style)effect-consistency.js- Effect-specific patterns (bannew Promise)type-safety.js- Exhaustive switch checks, nullish coalescing, optional chain, consistent type exportstesting.js- Test-specific rules (vitest globals, no.only())unicorn.js- Modern JS best practices fromeslint-plugin-unicorn
// Example: ban drizzle-orm imports outside packages/infra/db
{
"no-restricted-imports": [
{
name: "drizzle-orm",
message: "drizzle-orm may only be imported from packages/infra/db"
}
]
}The following table summarizes the key rules enforced via ESLint:
| Rule | What it prevents |
|---|---|
No drizzle-orm imports outside packages/infra/db | Direct DB access from app or domain code |
No effect imports from apps/web | Effect dependencies leaking into the web client |
No console.* in source modules | Unstructured logging; use @tx-agent-kit/logging instead |
No process.env in domain/services code | Environment coupling in business logic |
No as any assertions in source modules | Unsafe type coercions bypassing the type system |
No @ts-ignore or eslint-disable directives in source | Suppressed warnings hiding real issues |
| No default exports in domain layer files | Inconsistent import naming across consumers |
| No nested ternaries | Unreadable conditional chains; use if/else or && chains |
No new Promise() in core/API source | Raw promise constructors; use Effect.promise/Effect.tryPromise |
No require() in TypeScript | CommonJS imports; use ESM import |
No floating void expressions | Error swallowing; use await, .catch(), or Effect.runFork |
| No relative cross-package imports | Traversal imports; use @tx-agent-kit/* aliases |
| Explicit return types on port functions | Missing contracts on exported port boundaries |
No instanceof in domain layer | Runtime type checks; use discriminated unions with _tag |
No Object.assign in domain layer | Mutation; use object spread { ...obj } |
| Exhaustive switch statements on unions/enums | Missing cases in discriminated union handling |
Prefer ?? over || for nullish checks | Falsy-but-valid values (0, '', false) coerced away |
Prefer optional chain a?.b | Verbose manual null checks a && a.b |
Consistent type exports (export type) | Type-only exports missing the type keyword |
| No unnecessary conditions | Dead code branches; conditions always true/false |
| No switch fallthrough | Missing break/return in switch cases (TypeScript compiler) |
No .only() in test files | A committed .only() silently skips the rest of the suite in CI |
| Auto-remove unused imports | Dead imports cluttering modules |
| No constant binary expressions | Provably-wrong logic like "hello" ?? "world" |
| Curly braces on all blocks | Ambiguous single-line if statements |
| Object shorthand | Verbose { foo: foo } instead of { foo } |
| Arrow callbacks | Anonymous function expressions in callbacks |
...args over arguments | Legacy arguments object usage |
Spread over .apply() | Verbose .apply() calls |
node: protocol for builtins | Bare fs instead of node:fs |
.replaceAll() over .replace(/…/g) | Global regex when .replaceAll() suffices |
.slice() over .substring() | Legacy .substring()/.substr() methods |
.flatMap() over .map().flat() | Two-step chain when .flatMap() suffices |
.some() over .filter().length | Materializing a full array to check existence |
structuredClone over JSON round-trip | JSON.parse(JSON.stringify()) for cloning |
Number.isNaN over global isNaN | Global isNaN coerces non-numbers |
Consistent error naming in catch | Non-standard names like catch (e) |
| Numeric separators for large literals | 1000000 instead of 1_000_000 |
No blanket eslint-disable | /* eslint-disable */ without specific rule |
| Drop unused catch bindings | catch (error) when error is unused |
2. Structural invariant checks
Some constraints cannot be expressed as ESLint rules because they span multiple files or require AST-level analysis across the project. These are handled by scripts/lint/enforce-domain-invariants.mjs:
# Run structural checks
node scripts/lint/enforce-domain-invariants.mjsThe following table summarizes the structural invariants:
| Invariant | Scope |
|---|---|
Every .tsx file starts with 'use client' | apps/web/app/ and apps/web/components/ |
Port files do not contain export interface declarations | ports/*.ts across all domains |
Domain record types are defined in domain/ and imported by ports/ | DDD layer boundaries |
No repositories/ or services/ directories | Inside packages/core/src/domains/ |
Only apps/web/lib/auth-token.ts manages browser-visible auth token state | Entire apps/web tree |
| Env reads go through dedicated env modules only | All source modules |
Test files follow colocated naming (.test.ts, .integration.test.ts) | Entire repo |
No __tests__ directories, .spec.ts, or .integration.ts files | Entire repo |
3. Shell invariant checks
Some invariants are best verified by shell scripts, particularly those involving file system structure, binary availability, or cross-cutting concerns:
# Run shell invariant checks
bash scripts/check-shell-invariants.shShell checks verify that required binaries are available, Docker services are configured correctly, file permissions are set appropriately, and generated files are up to date.
The unified lint command
All three layers are unified under a single command:
pnpm lintThis runs ESLint, structural invariants, and shell invariants in sequence. If any layer fails, the overall command fails. This means a single pnpm lint call verifies all architectural constraints.
For less verbose output during iteration:
pnpm lint:quietAdding new enforcement
When you discover a convention that is being violated, the process for adding enforcement is:
- Identify the class of violation. Is it an import pattern (ESLint)? A file structure issue (structural)? A system-level concern (shell)?
- Write the check. Add an ESLint rule, a structural invariant function, or a shell check.
- Verify it catches the violation. Run the check against the current codebase.
- Fix any existing violations. The codebase must be green before the check can be enabled.
- Document the constraint in
CLAUDE.md. Agents need to know the "why" behind the rule.
This process ensures that every new constraint is immediately actionable. There is no gap between "we decided this" and "this is enforced."
Why not just code review?
Code review is valuable for intent verification: does this code do what we want? But code review is a poor enforcement mechanism for structural conventions. Different reviewers catch different things, so enforcement is inconsistent. Feedback comes hours or days after the code is written, so the loop is slow. Agents cannot learn from code review comments on other PRs, so it does not scale to AI workflows. And nitpicking structural issues in review is demoralizing for developers, creating unnecessary friction.
Mechanical enforcement handles structural conventions instantly, consistently, and automatically. Code review can then focus on what it does best: verifying intent and catching logical errors.