tx-agent-kit
Philosophy

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 - strictTypeChecked preset and project-wide rule overrides
  • domain-invariants.js - Architecture and DDD layer boundary rules
  • code-quality.js - General code quality (unused imports, curly braces, modern JS style)
  • effect-consistency.js - Effect-specific patterns (ban new Promise)
  • type-safety.js - Exhaustive switch checks, nullish coalescing, optional chain, consistent type exports
  • testing.js - Test-specific rules (vitest globals, no .only())
  • unicorn.js - Modern JS best practices from eslint-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:

RuleWhat it prevents
No drizzle-orm imports outside packages/infra/dbDirect DB access from app or domain code
No effect imports from apps/webEffect dependencies leaking into the web client
No console.* in source modulesUnstructured logging; use @tx-agent-kit/logging instead
No process.env in domain/services codeEnvironment coupling in business logic
No as any assertions in source modulesUnsafe type coercions bypassing the type system
No @ts-ignore or eslint-disable directives in sourceSuppressed warnings hiding real issues
No default exports in domain layer filesInconsistent import naming across consumers
No nested ternariesUnreadable conditional chains; use if/else or && chains
No new Promise() in core/API sourceRaw promise constructors; use Effect.promise/Effect.tryPromise
No require() in TypeScriptCommonJS imports; use ESM import
No floating void expressionsError swallowing; use await, .catch(), or Effect.runFork
No relative cross-package importsTraversal imports; use @tx-agent-kit/* aliases
Explicit return types on port functionsMissing contracts on exported port boundaries
No instanceof in domain layerRuntime type checks; use discriminated unions with _tag
No Object.assign in domain layerMutation; use object spread { ...obj }
Exhaustive switch statements on unions/enumsMissing cases in discriminated union handling
Prefer ?? over || for nullish checksFalsy-but-valid values (0, '', false) coerced away
Prefer optional chain a?.bVerbose manual null checks a && a.b
Consistent type exports (export type)Type-only exports missing the type keyword
No unnecessary conditionsDead code branches; conditions always true/false
No switch fallthroughMissing break/return in switch cases (TypeScript compiler)
No .only() in test filesA committed .only() silently skips the rest of the suite in CI
Auto-remove unused importsDead imports cluttering modules
No constant binary expressionsProvably-wrong logic like "hello" ?? "world"
Curly braces on all blocksAmbiguous single-line if statements
Object shorthandVerbose { foo: foo } instead of { foo }
Arrow callbacksAnonymous function expressions in callbacks
...args over argumentsLegacy arguments object usage
Spread over .apply()Verbose .apply() calls
node: protocol for builtinsBare 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().lengthMaterializing a full array to check existence
structuredClone over JSON round-tripJSON.parse(JSON.stringify()) for cloning
Number.isNaN over global isNaNGlobal isNaN coerces non-numbers
Consistent error naming in catchNon-standard names like catch (e)
Numeric separators for large literals1000000 instead of 1_000_000
No blanket eslint-disable/* eslint-disable */ without specific rule
Drop unused catch bindingscatch (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.mjs

The following table summarizes the structural invariants:

InvariantScope
Every .tsx file starts with 'use client'apps/web/app/ and apps/web/components/
Port files do not contain export interface declarationsports/*.ts across all domains
Domain record types are defined in domain/ and imported by ports/DDD layer boundaries
No repositories/ or services/ directoriesInside packages/core/src/domains/
Only apps/web/lib/auth-token.ts manages browser-visible auth token stateEntire apps/web tree
Env reads go through dedicated env modules onlyAll source modules
Test files follow colocated naming (.test.ts, .integration.test.ts)Entire repo
No __tests__ directories, .spec.ts, or .integration.ts filesEntire 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.sh

Shell 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 lint

This 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:quiet

Adding new enforcement

When you discover a convention that is being violated, the process for adding enforcement is:

  1. Identify the class of violation. Is it an import pattern (ESLint)? A file structure issue (structural)? A system-level concern (shell)?
  2. Write the check. Add an ESLint rule, a structural invariant function, or a shell check.
  3. Verify it catches the violation. Run the check against the current codebase.
  4. Fix any existing violations. The codebase must be green before the check can be enabled.
  5. 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.

On this page