The Six-Layer Lint Harness: What Actually Scales Agent-Written Code

James Phoenix
James Phoenix

Rules eliminate entire bug classes permanently. But rules alone aren’t enough. You need the three-legged stool: structural constraints, behavioral verification, and generative scaffolding.

Author: James Phoenix | Date: March 2026


Summary

This article documents a real production harness across a TypeScript monorepo (tx-agent-kit: 15 packages, 234 integration tests, ~1200 lines of domain invariants). It covers: the six layers from compiler flags to structural scripts, the override strategy for generated code and tests, real bugs found during hardening that exposed config traps in ESLint, an honest ROI assessment of each rule category, and the “three-legged stool” framework for what actually works when scaling with coding agents.


The Core Idea

If a bug class can be caught mechanically, it should never reach code review.

Humans are inconsistent at spotting structural violations. Machines are perfect at it. You encode your architectural decisions as lint rules and compiler flags. Every pnpm lint run becomes a mechanical proof that the architecture hasn’t drifted.

This is constraint-first development applied to lint infrastructure.


The Six Layers

From innermost (language-level) to outermost (architecture-level):

Layer 1: TypeScript Compiler        ← Zero cost, catches type errors
Layer 2: strictTypeChecked Preset   ← Type-aware lint, catches null/error bugs
Layer 3: Cherry-Picked Type Safety  ← Exhaustive switches, nullish coalescing
Layer 4: Code Quality               ← No nested ternary, no require, no void
Layer 5: Domain Invariants          ← DDD boundaries, schema isolation, determinism
Layer 6: Structural Scripts          ← Cross-file invariants ESLint can't express

Each layer catches a different class of problem. Together they form a harness that prevents architectural drift across unlimited agent sessions.


Layer 1: TypeScript Compiler

File: packages/tooling/typescript-config/base.json

Flag What it prevents
strict: true Baseline: strictNullChecks, noImplicitAny, strictFunctionTypes. Without this, TypeScript is just JavaScript with extra syntax.
noUncheckedIndexedAccess array[0] returns T | undefined instead of T. Forces handling of missing indices. Agents frequently write users[0].name without checking.
noImplicitReturns Every code path must explicitly return. Prevents accidentally returning undefined by falling off the end.
noFallthroughCasesInSwitch Every case must end with break, return, or throw. Agents regularly forget break.

Why this layer matters: Zero runtime cost, zero maintenance. The compiler checks them on every build. They catch the bugs that are embarrassing to find in production.


Layer 2: strictTypeChecked Preset

File: packages/tooling/eslint-config/base.js

The upgrade from recommendedTypeChecked to strictTypeChecked added ~20 type-aware rules. Key categories:

Null/Undefined Safety

Rule What it catches Example
no-unnecessary-condition Conditions that are always true/false if (x) where x: string (never null)
restrict-template-expressions Non-string values silently stringified `value: ${undefined}` becomes "value: undefined"
no-confusing-void-expression Using void values where real values expected return arr.forEach(...)

Error Handling

Rule What it catches Example
only-throw-error Throwing non-Error values throw 'something broke' loses stack trace
prefer-promise-reject-errors Rejecting with non-Error values reject('failed') loses stack trace
use-unknown-in-catch-callback-variables .catch(err) where err is typed as any Forces unknown type, requiring explicit checks

Code Precision

Rule What it catches
no-unnecessary-type-parameters Generics that add complexity without value
unified-signatures Overloads that could be a single signature
no-dynamic-delete delete obj[key] mutations via dynamic keys

Layer 3: Cherry-Picked Type Safety

File: packages/tooling/eslint-config/type-safety.js

Four rules not in any preset but high-value for catching agent mistakes:

switch-exhaustiveness-check (the most important rule for DDD)

type Status = 'pending' | 'active' | 'suspended'

function getLabel(status: Status): string {
  switch (status) {
    case 'pending': return 'Pending'
    case 'active': return 'Active'
    // ERROR: 'suspended' not handled
  }
}

When you add a new value to a union type, every switch statement that handles that type fails lint until you add the new case. Domain model changes ripple through the codebase mechanically. Without this rule, adding a new enum value is a game of “did I find all the places?”

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course

prefer-nullish-coalescing

const port = config.port || 3000    // BUG: port 0 is valid but falsy
const port = config.port ?? 3000    // CORRECT: only null/undefined trigger fallback

The || operator treats 0, '', false, and NaN as falsy. The ?? operator only triggers on null/undefined.

prefer-optional-chain

if (user && user.address && user.address.city)    // verbose, easy to mistype
if (user?.address?.city)                           // same semantics, less error-prone

consistent-type-exports

export { type User, type Team }     // CORRECT: signals "types only" to bundlers
export { User, Team }               // ERROR: may confuse tree-shaking

Layer 4: Code Quality

File: packages/tooling/eslint-config/code-quality.js

Rule What it prevents Why
no-nested-ternary a ? b ? c : d : e Unreadable. Use if/else or extracted variables.
no-require-imports require('x') in TypeScript Forces ESM imports so tree-shaking works and types flow.
no-void void doSomething() Silently swallows rejected promises. Use await, .catch(), or Effect.runFork.

Layer 5: Domain Invariants (The Real Power)

File: packages/tooling/eslint-config/domain-invariants.js (~1200 lines)

This is where constraint-driven development shines. These aren’t TypeScript rules. They’re architecture rules encoded as lint constraints. They enforce DDD layer boundaries, hexagonal architecture, and operational contracts mechanically. See ESLint Rules and Enforcement Overview for the full rule reference.

5a. Dependency Direction

DDD layer structure: domain -> ports -> application -> adapters -> runtime. Dependencies must point inward only. See DDD Construction Pattern and Dependency Flow for the full architecture.

Rule What it enforces
enforce-layer-boundaries Domain can’t import ports, ports can’t import application, etc.
pure-domain-no-infra-imports Domain can’t import @tx-agent-kit/db, node:fs, etc.
pure-domain-no-effect-imports Domain entities must be pure TypeScript, no Effect dependency.
adapters-must-import-port Adapters must reference the port they implement.
ports-no-layer-providers Ports can’t import Effect Layer wiring.

Why this matters: If an agent adds a database call inside a domain entity, lint fails instantly. You don’t need a human to catch “you put infrastructure in the domain layer.”

5b. Schema Isolation

// Ban ALL competing schema libraries
'no-restricted-imports': ['error', {
  paths: [
    { name: 'zod', message: 'Use effect/Schema only' },
    { name: 'valibot', ... },
    { name: 'yup', ... },
    { name: 'joi', ... },
    { name: 'superstruct', ... }
  ]
}]

One schema library per codebase. An agent trained on Stack Overflow will happily import { z } from 'zod'. This rule stops it. See Schema Contracts for how Effect Schema is used throughout.

5c. ORM Isolation

Only packages/infra/db may import drizzle-orm. Routes, services, domain code can’t import { eq } from 'drizzle-orm'. All persistence goes through repositories exposed as Effect Services via the Ports and Adapters pattern.

5d. Client App Isolation

Web and mobile apps are “dumb” API consumers with no domain knowledge:

apps/web can't import:
  - @tx-agent-kit/db       (no direct DB access)
  - drizzle-orm            (no ORM)
  - effect                 (no Effect runtime)
  - next/server            (no server-side code)

Single entry points enforced:

localStorage  -> only via lib/auth-token.ts
process.env   -> only via lib/env.ts
notifications -> only via lib/notify.tsx
URL state     -> only via lib/url-state.tsx
HTTP client   -> only via lib/axios.ts

Why single entry points? If you need to change how auth tokens are stored (localStorage to cookies), you change exactly one file. Without this rule, 47 components each reading localStorage directly creates a refactoring nightmare.

5e. Domain Determinism

In domain/ports/application layers, ban Date.now(), new Date(), Math.random(). Inject via ports (ClockPort, RandomPort).

Domain logic must be deterministic. If your billing calculation uses new Date() internally, you can’t test it reliably. By injecting time through ports, tests provide fixed values.

5f. Temporal Workflow Determinism

In workflow files, ban Date.now(), setTimeout, process.env, @tx-agent-kit/db, @tx-agent-kit/logging.

Temporal workflows replay from event history. If a workflow uses Date.now(), it returns a different value on replay than on original execution, causing non-determinism bugs that are extremely hard to debug. See Worker (Temporal) and Adding Workflows for the full workflow setup.

5g. Outbox Pattern Enforcement

API must NOT import @temporalio/*. API writes to an outbox table, worker reads from it. This guarantees at-least-once delivery without distributed transactions.

5h. Effect Runtime Boundaries

Ban Effect.run* in source modules except explicit boundaries. Ban new Promise() in core/API (use Effect.promise/tryPromise). Effect code stays declarative until the edge of the system.


Layer 6: Structural Scripts

Directory: scripts/lint/

Some invariants can’t be expressed as ESLint rules because they span multiple files or require custom parsing. See Structural Checks and Shell Invariants for the full enforcement suite.

Script What it enforces
enforce-domain-invariants.mjs Table-to-schema parity, table-to-factory parity, route/repository kind markers
enforce-web-client-contracts.mjs Every web page uses 'use client' directive
enforce-route-kind-contracts.mjs CRUD routes expose full CRUD surface, custom routes don’t
enforce-source-type-safety.mjs No as any, no chained assertions, no suppression directives
enforce-domain-event-contracts.mjs Every domain event has registered type, payload, schema, and exhaustive switch default
enforce-tsconfig-alignment.mjs All tsconfigs extend the shared base
enforce-compose-runtime-contracts.mjs Docker compose and K8s configs are consistent

The Override Strategy

Not every file needs every rule. The config has a deliberate hierarchy:

Override Why Rules disabled
Generated code (Orval output) Codegen output can’t conform to hand-authored rules. If the generator is correct, the output is correct. All no-unsafe-*, no-redundant-type-constituents, no-misused-spread, consistent-type-exports
Orval mutators (hand-authored) Transport adapters that work with Axios internals and any-typed HTTP responses. Need no-unsafe-* relaxed but keep safety rules enabled. no-unsafe-assignment, no-unsafe-member-access, no-unsafe-argument, no-misused-spread
Test files Tests need ! assertions, void expressions, dynamic deletes, and template interpolation of undefined values. no-non-null-assertion, restrict-template-expressions, no-confusing-void-expression, no-dynamic-delete, no-unnecessary-condition
DB schema Drizzle’s pgTable API is marked deprecated in newer TS types but there’s no migration path yet. no-deprecated

Key lesson learned: Config ordering matters. If type-safety.js loads after base.js in the ESLint config chain, rules in type-safety.js override the overrides from base.js. Generated file overrides must exist in every config file that enables the relevant rule.


Real Bugs Found During Hardening

These were discovered by running agent swarms (code reviewers, smoke testers, regression auditors) against the harness itself:

1. The restrict-template-expressions Config Reset Trap

Setting { allowNumber: true } alone resets all other options to their defaults (all true), silently undoing strictTypeChecked. You must explicitly set every option:

// BAD: silently allows boolean, nullish, any, regexp, never
{ allowNumber: true }

// GOOD: explicit about what's allowed
{
  allowNumber: true,
  allowBoolean: false,
  allowNullish: false,
  allowAny: false,
  allowRegExp: false,
  allowNever: false
}

2. The prefer-nullish-coalescing Semantic Risk

The rule auto-fixes '' || fallback to '' ?? fallback, changing behavior for empty strings. Fixed with ignorePrimitives: { string: true } because '' || fallback is often intentional.

3. The Orval Mutator Over-Permission

Hand-authored transport adapters were grouped with generated code overrides, disabling safety rules unnecessarily. Split into separate override with only the minimum rules disabled.

4. The Config Ordering Bug

type-safety.js came after base.js in the config chain, so consistent-type-exports: error from type-safety.js overrode the off in generated file overrides in base.js. Fix: add the generated file override directly in type-safety.js.

5. The Mutable Guard Pattern

TypeScript narrows guard.active through async boundaries even though the value can change after await. Fix: use a function accessor isActive() to prevent incorrect narrowing.


ROI Assessment: Not All Rules Are Equal

High ROI (invest heavily here)

Rule Bug prevented Agent frequency ROI
switch-exhaustiveness-check Missing case in union handler Very common Extremely high
no-restricted-imports (drizzle isolation) ORM leaking outside DB layer Common Very high
DDD layer boundaries Architecture drift Common Very high
no-unnecessary-condition Dead code or wrong type Moderate High
restrict-template-expressions undefined in strings Common High

Low ROI (keep but don’t over-invest)

Rule What it catches Frequency ROI
no-dynamic-delete delete obj[key] Rare Low
unified-signatures Overloads that could merge Very rare Low
no-meaningless-void-operator void on void expressions Almost never Negligible

Practical advice: Invest most heavily in rules that catch the bugs agents actually make (exhaustive switches, null safety, layer boundary violations). Be skeptical of rules that mostly catch things humans do but agents don’t.


The Three-Legged Stool

Constraint-driven development (the lint harness) is necessary but not sufficient. It’s one leg of a three-legged stool:

┌────────────────────────┬──────────────────────────┬──────────────────────┐
│  Structural Constraints │  Behavioral Verification │  Generative          │
│  (Lint Rules)           │  (Integration Tests)     │  Scaffolding         │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ Prevent known           │ Verify the system works  │ Produce correct      │
│ anti-patterns           │ correctly end-to-end     │ structures by        │
│                         │                          │ default              │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ ~1200 lines of domain   │ 234 integration tests    │ pnpm scaffold:crud   │
│ invariants, 6 layers    │ against real Postgres    │ generates 15+ files  │
│ of rules                │ and Temporal             │ conforming to DDD    │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ Can check structure,    │ Can verify behavior,     │ Can produce correct  │
│ NOT logic               │ NOT structure            │ starting points      │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ Alone: structurally     │ Alone: behaviorally      │ Alone: correct       │
│ correct but potentially │ correct but structurally │ starts that diverge  │
│ behaviorally broken     │ drifting                 │ over time            │
└────────────────────────┴──────────────────────────┴──────────────────────┘

Leg 1: Structural Constraints (this article)

Rules check structure, not logic. This code passes every lint rule:

export const calculateDiscount = (price: number, tier: string): number => {
  if (tier === 'premium') return price * 0.5
  if (tier === 'standard') return price * 0.2
  return price * 0.1
}
// Structurally perfect. Logically wrong.
// Business requirement: premium = 20% off, standard = 10% off, free = none

Leg 2: Behavioral Verification

Integration tests against real infrastructure verify behavior. Run real HTTP requests against a real database. Verify that sign-up, sign-in, token refresh, and organization creation all work. No lint rule can do that. See Testing Strategy for the full approach.

Leg 3: Generative Scaffolding

Code generators produce correct structures by default. pnpm scaffold:crud generates the entire DDD structure: domain types, ports, application layer, adapters, routes, tests. Instead of writing 15 files and hoping they conform, the scaffold produces a correct skeleton that the agent fills in. See Adding a Domain for the scaffold walkthrough.

OpenAPI spec to Orval-generated typed clients is the same idea. The agent never writes API client code. It’s generated from the contract.


Where Constraints Fall Short

Rules can only prevent what you’ve anticipated

Every rule exists because someone thought of that failure mode. Novel architectural drift slips through. The rules are reactive: discover a pattern, encode a rule, prevent recurrence. They don’t prevent the first occurrence.

What fills this gap: Code review by experienced humans or AI reviewers. The review agents in this session found the restrict-template-expressions config bug, the orval-mutator scope problem, and the isActive() guard pattern.

Rule maintenance is a real cost

When the architecture changes, you update the rules too. Rules that don’t evolve become either too permissive (allowing violations of the new architecture) or too restrictive (blocking legitimate new patterns, leading to exception requests).

The config ordering bug in this session is a rule maintenance bug. The system enforcing correctness had its own correctness problems.

Over-constraining creates friction without proportional safety

Low-ROI rules add cognitive overhead (developers/agents must learn them, understand the override system, work around false positives) without proportional safety benefit.


The Full Verification Suite

pnpm lint
  ├── ESLint (14 packages)
  │     ├── base.js              -- strictTypeChecked + explicit overrides
  │     ├── type-safety.js       -- exhaustive switches, ??, ?., type exports
  │     ├── code-quality.js      -- no nested ternary, no require, no void
  │     ├── effect-consistency.js -- no new Promise in Effect code
  │     ├── domain-invariants.js  -- 1200+ lines of architecture rules
  │     ├── boundaries.js        -- import boundary constraints
  │     ├── promise.js           -- promise handling rules
  │     └── testing.js           -- test-specific rules
  ├── Structural invariants (7 scripts)
  ├── Shell invariants
  └── CI env validation

Every run is a mechanical proof that:

  1. Types are sound (compiler)
  2. Null safety is maintained (strictTypeChecked)
  3. Switch statements are exhaustive (type-safety)
  4. DDD layers aren’t violated (domain-invariants)
  5. Client apps don’t know about the server (domain-invariants)
  6. Workflows are deterministic (domain-invariants)
  7. Events flow through the outbox (domain-invariants)
  8. Tables have schemas and factories (structural scripts)
  9. No code smells are hiding (code-quality)

The key insight: The rules ARE the architecture documentation. If someone asks “can the web app import from the database layer?” the answer isn’t in a wiki page that might be outdated. It’s in a lint rule that fails the build if violated.


External

Related

Topics
Architecture EnforcementCase StudyConstraint DrivenDomain InvariantsEslintIntegration TestingLint HarnessOverride StrategyScaffoldingScaling Agents

More Insights

Cover Image for ASCII Previews Before Expensive Renders

ASCII Previews Before Expensive Renders

Image and video generation are among the most expensive API calls you can make. A single image render costs $0.02-0.20+, and video generation can cost dollars per clip. Before triggering these renders

James Phoenix
James Phoenix
Cover Image for The Rise of the AI Engineer

The Rise of the AI Engineer

A new engineering role is emerging between ML research and software engineering, focused on building products with foundation models via APIs.

James Phoenix
James Phoenix