Note · · 11 min read

Technical guardrails for LLM-assisted code: the full catalog

A defense-in-depth reference for Node/NestJS teams: nine layers of automated guardrails, from agent hooks and compiler strictness to CI gates and runtime rollback, each one catching what the previous one missed.

Our report More code, less confidence maps where quality breaks in AI-assisted teams: pull requests 2.5x larger, duplicated blocks up 81% in three years, error-masking constructs up 47%, review time up 441%. It ends on the habits of the teams that escape it — small batches, gates in CI, a Definition of Done that doesn’t bend.

This is the other half: the concrete catalog behind those habits. Each layer catches what the previous one missed. The failure modes they target are the ones in the 2025–26 data — oversized PRs, duplication, error-masking, “almost right” code, hallucinated dependencies, and review overload.

Ordering principle: the cheapest gate is the one closest to the moment code is written. Agent hooks, then pre-commit, then CI, then review, then production — in rising order of cost. Push every check as far left as it can run.


Layer 0 — Agent and editor context (before code exists)

Prevention starts before the LLM writes a line. Most “bad AI code” is the model guessing conventions it was never given.

MeasureToolingWhat it prevents
Repo context filesAGENTS.md / CLAUDE.md / .cursor/rules / .github/copilot-instructions.mdModel inventing conventions: naming, layer rules, banned patterns, “always use our @app/http-client, never raw axios”
Machine-readable conventionsdocs/adr/ (ADRs), CONTRIBUTING.md, example modules as templatesArchitecture drift; the agent copies the golden path instead of imagining one
Scaffolding by generator, not freeformNest CLI schematics, Nx generators, Hygen/Plop templatesStructural inconsistency: every module gets identical shape (controller/service/repo/dto/spec)
Pinned toolchainVolta / mise / .nvmrc / packageManager field + corepack, devcontainer”Works on the agent’s machine”: agent and CI run identical Node, pnpm, tsc versions
Internal libs surfaced to the agentMonorepo path aliases, published typedocs, MCP servers over internal docsDuplication: the model reuses @app/* packages instead of re-implementing them (the GitClear failure mode)
Editor-integrated feedbackESLint/tsc/Prettier on save in IDE and in the agent loopErrors discovered at CI instead of at write time

Layer 1 — Compiler and type system

The cheapest reviewer you have. Make it maximally strict; LLM code that “almost works” often dies right here.

MeasureConfigWhat it prevents
Full strict mode"strict": true plus noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch, noPropertyAccessFromIndexSignatureWhole classes of “almost right”: undefined access, silent optional mismatches
No escape hatches@typescript-eslint/no-explicit-any, no-unsafe-* family, ban-ts-comment (require description, forbid @ts-ignore)The model silencing the compiler instead of fixing the type
Type coverage as a metrictype-coverage with a ratchet (never below current %)Gradual any erosion across hundreds of small AI commits
Typecheck as a standalone gatetsc --noEmit in pre-push and CI (not only via bundler)Bundlers (esbuild/SWC) that transpile without checking, shipping type errors

Layer 2 — Linting, complexity and hygiene

This is where you encode “bring sense to the code” as executable rules instead of review comments.

Core: typescript-eslint with strict-type-checked + stylistic-type-checked presets (or Biome if you trade rule depth for speed). --max-warnings 0 — a warning is a future ignored error.

ConcernRules / toolsWhat it prevents
Complexity budgetscomplexity (cyclomatic), sonarjs/cognitive-complexity, max-lines, max-lines-per-function, max-depth, max-params300-line generated functions nobody can review
Duplicationjscpd in CI with threshold (e.g. fail above 3% new duplication), SonarQube duplication on new codeThe +81% copy/paste trend, directly
Error maskingno-empty (no empty catch), @typescript-eslint/no-floating-promises, no-misused-promises, eslint-plugin-promise, prefer-promise-reject-errors, forbid catch {} and .catch(() => {}) via no-restricted-syntaxThe +47% rise in constructs that swallow failures — and unhandled rejections killing a Nest process
Dead codeknip (unused files, exports, dependencies), no-unused-vars, unused-importsOrphan helpers the agent generated “just in case”
Import disciplineeslint-plugin-import (no-cycle, no-extraneous-dependencies, order), no-restricted-importsUndeclared deps, tangled import graphs, reaching into other modules’ internals
Pattern bansno-restricted-syntax / no-restricted-properties: ban process.env outside the config module, raw console.* (force the logger), new Date() in domain logic (force clock provider), raw SQL outside repositoriesThe model bypassing your abstractions because it doesn’t know they’re mandatory
Zero-diff formattingPrettier or Biome, enforced in hook + CIFormatting noise inflating AI diffs and hiding real changes in review
Naming and API shape@typescript-eslint/naming-convention, explicit-function-return-type on exported APIsInconsistent public surfaces across generated modules
Custom org rulesLocal ESLint plugin (or GritQL/ast-grep rules)Anything you keep repeating in review: encode it once, never comment it again

Layer 3 — Architecture enforcement (NestJS scope rules)

Linting checks lines; this layer checks the shape. It’s the answer to “rules for ensuring scope of NestJS architecture” — make the layer diagram executable.

dependency-cruiser — the workhorse. Declare what may import what; CI fails on violation:

// .dependency-cruiser.cjs — examples
module.exports = {
  forbidden: [
    { name: 'no-circular', severity: 'error', from: {}, to: { circular: true } },
    { name: 'domain-is-pure',            // domain imports nothing from infra/http
      from: { path: '^src/.*/domain' },
      to:   { path: '^src/.*/(infrastructure|controllers|typeorm|prisma)' } },
    { name: 'controllers-use-services',  // controllers never touch repositories
      from: { path: '\\.controller\\.ts$' },
      to:   { path: '(\\.repository\\.ts$|/entities/)' } },
    { name: 'no-cross-module-internals', // other modules only via its public index
      from: { path: '^src/modules/([^/]+)/' },
      to:   { path: '^src/modules/(?!$1)[^/]+/(?!index\\.ts)' } },
  ],
};
MeasureToolingWhat it prevents
Dependency rules + orphan/circular detectiondependency-cruiser (graph output doubles as living architecture doc)Controllers→DB shortcuts, cross-domain reach-ins, cycles
Circular imports (fast check)madge --circular srcNest DI failures (forwardRef creep) and unloadable module graphs
Tagged module boundaries in monoreposNx @nx/enforce-module-boundaries with tags (type:feature, type:data-access, scope:billing) or eslint-plugin-boundariesscope:billing importing scope:orders internals; util libs importing features
Architecture as unit teststs-arch (“classes in domain/ depend only on domain/“)Regressions in rules too nuanced for path globs
Nest module encapsulationMinimal exports per module; barrel-file discipline; forbid @Global() except allow-listed”Everything is public so everything gets coupled”
DI disciplineBan new SomeService() in app code via no-restricted-syntax; constructor injection onlyThe model instantiating services by hand and bypassing lifecycle, scopes and testability

Layer 4 — Data and API contracts

The “almost right” code that hurts most fails at boundaries. Pin every boundary with a schema.

MeasureToolingWhat it prevents
Runtime validation of every inputNest ValidationPipe with whitelist, forbidNonWhitelisted and transform enabled globally + class-validator DTOs, or nestjs-zodUnvalidated fields flowing in because the model trusted the client
Typed, validated config@nestjs/config + Zod/Joi schema on startup — fail fast on missing envHallucinated env vars discovered in production
API contract lint + breaking-change gate@nestjs/swagger generated spec + Spectral lint + oasdiff/openapi-diff vs main in CISilent breaking changes to consumers inside a big generated diff
Consumer contract testsPact (if you have internal consumers)“It compiles here” breaking another team
Serialization controlClassSerializerInterceptor + explicit @Expose/@ExcludeEntity fields (password hashes, internal ids) leaking into responses
Migration gatesMigrations only (never synchronize: true); prisma migrate diff / TypeORM schema:log clean check; squawk lint for destructive Postgres DDL; require down-migrationsThe model “fixing” a bug by mutating schema, or dropping a column in a 400-line PR

Layer 5 — Local git hooks (pre-commit / pre-push)

Fast feedback before anything leaves the machine. Keep pre-commit under ~5 s or people (and agents) will bypass it.

MeasureToolingWhat it prevents
Staged-files lint + format + related testshusky + lint-staged (ESLint --fix, Prettier, optionally vitest related --run)Broken style/lint ever entering history
Full typecheck + affected tests on pushpre-push hook: tsc --noEmit + nx affected -t test / turbo run test --filter=...[origin/main]Pushing red builds; keeps pre-commit fast by deferring slow checks
Secret scanninggitleaks or trufflehog as pre-commit + CI backstopAPI keys the model copied from context into code
Commit message contractcommitlint + Conventional Commits (feat(billing): ...)Unreadable history; enables semantic-release and scope-based review routing
Commit size guardCustom hook or Danger local run warning/failing above ~300–400 changed LOCThe 2.5x oversized-diff habit, stopped at the source
Branch hygieneHook blocking direct commits to main/master”The agent pushed to main”
No bypass culture--no-verify allowed only with HOOK_BYPASS_REASON= env, logged in CIGuardrails that exist on paper only

Layer 6 — Agent-native guardrails (when the LLM finishes, before commit)

The newest layer: deterministic hooks around the agent itself, so verification happens before a human ever sees the diff.

MeasureToolingWhat it prevents
Post-edit hooksClaude Code hooks (PostToolUse → run eslint --fix + tsc --noEmit on touched files); equivalent hooks in Cursor and other agentsThe agent accumulating 40 broken files before anyone notices
”Done” gateStop-hook that runs lint + typecheck + affected tests and refuses completion until green”I’m finished” with failing tests — the agent must prove done
Scope confinementAgent permission config: deny-list .env*, migrations/, .github/workflows/, lockfiles, auth/ + payment paths; allow-list working dirsThe model “helpfully” editing CI, secrets, or the money path
Plan-before-applyRequire plan/diff approval for changes touching more than N files or protected pathsSprawling refactors nobody asked for
Sandboxed executionDevcontainer/VM: no prod credentials, egress-restricted network, disposable filesystemPrompt-injected or hallucinated commands reaching real systems
Independent verifier passSecond model/session prompted to refute the change (“find why this is wrong”) before PRAuthor-bias: the same context that wrote the bug approving it
Tests move with codeAgent instructions + Danger rule: src/** changes without *.spec.ts changes failGenerated code with zero or assertion-free tests
ProvenanceCommit trailer / PR label AI-assisted: yes + session log attachedReviewers calibrating scrutiny blind; no audit trail

Example (Claude Code, .claude/settings.json):

{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{ "type": "command",
        "command": "npx eslint --fix $CLAUDE_FILE_PATHS && npx tsc --noEmit" }]
    }],
    "Stop": [{
      "hooks": [{ "type": "command",
        "command": "pnpm lint && pnpm typecheck && pnpm test:affected" }]
    }]
  }
}

Layer 7 — CI/CD pipeline gates (the PR wall)

Everything above runs advisory-fast; CI is where it becomes law. All gates as required status checks — non-required checks are decoration.

Correctness and tests

MeasureToolingWhat it prevents
Build + typecheck + lint (zero warnings)tsc --noEmit, ESLint --max-warnings 0Baseline breakage
Coverage on new code (diff coverage)Vitest/Jest coverage + Codecov/Coveralls patch check (e.g. 80% or more on the diff) or Sonar “Clean as You Code”Coverage theater: global % stable while every new line ships untested
Mutation testing on critical modulesStryker (nightly or on core/, billing/, auth/ paths)AI-written tests that execute code but assert nothing
Real-infra integration testsTestcontainers (Postgres/Redis/Kafka) + supertest e2eCode that passes against mocks and dies against reality
Flaky-test quarantineRetry-with-report, quarantine tag, flake dashboardRed-is-normal culture that trains everyone to ignore CI
Quality gate on new codeSonarQube/SonarCloud: new duplication under 3%, new issues at zero, maintainability rating on diffDebt entering silently commit by commit
Duplication deltajscpd gate vs mainCopy/paste growth (the GitClear +81%)
PR size gateDanger.js / GitHub Action: fail or hard-label above ~400 changed lines (excluding lockfiles/generated)Unreviewable diffs; enforces the small-batch discipline DORA ties to elite performance
PR metadata contractDanger: linked ticket, description, risk note, test evidenceContext-free diffs that force reviewers to reverse-engineer intent
API breaking-change checkoasdiff vs main (from Layer 4)Accidental contract breaks

Security and supply chain

LLMs made this layer non-optional.

MeasureToolingWhat it prevents
SASTCodeQL and/or Semgrep (+ custom org rules)Injection, path traversal, authz mistakes in generated code
Dependency vettingSocket.dev / GitHub dependency review: block new deps younger than N days, typosquats, install scripts, no repoSlopsquatting — attackers pre-registering package names LLMs hallucinate
New-dependency approvalCI check: any new entry in package.json requires a labeled approvalThe model adding left-pad-utils-2 because it “seemed to exist”
Lockfile integritynpm ci/pnpm i --frozen-lockfile only; lockfile-lint (registry pinning, no git/http deps)Lockfile tampering and registry swaps hidden in big diffs
Vulnerability + license scanosv-scanner / npm audit signals via Renovate; license-checker gateKnown CVEs; GPL surprises in a commercial codebase
Controlled updatesRenovate with minimumReleaseAge (e.g. 7–14 days), grouped PRs, automerge patch-only when greenFresh-malicious-version attacks; agents bumping deps ad hoc
Secret scan (server-side)gitleaks in CI + GitHub push protectionKeys that slipped past local hooks
SBOM + provenanceSyft SBOM, npm provenance/SLSA attestations on your own publishesUnanswerable “are we affected?” moments

Repo policy

MeasureToolingWhat it prevents
Branch protectionRequired checks, at least one human review, dismiss stale approvals, no force-push, linear historyMerging around the gates
CODEOWNERS on critical pathsauth/, billing/, migrations/, .github/ → mandatory named reviewersHigh-blast-radius changes reviewed by whoever was fastest
No self-approval for bot PRsRule: agent-authored PRs cannot be approved by their invoker onlyRubber-stamping your own agent
Merge queueGitHub merge queue / MergifySemantically conflicting PRs that are individually green breaking main
AI first-pass reviewCodeRabbit / Greptile / Copilot code review / Claude as reviewer — advisory, never replacing the human gateReviewer fatigue on mechanical issues; frees humans for design and intent

Layer 8 — Post-merge and runtime (assume something got through)

MeasureToolingWhat it prevents
Feature flags + kill switchesOpenFeature + Unleash/Flagsmith; new AI-touched paths ship darkRollback requiring a redeploy at 3 a.m.
Progressive deliveryCanary/blue-green (Argo Rollouts), auto-rollback on error/latency burnFull-blast exposure of a defect to 100% of traffic
Observability contractOpenTelemetry + structured logs (pino) required on new endpoints; Sentry release tagging tied to PRs”It’s broken but we can’t see where”; regressions untraceable to the change that caused them
SLOs + error budget policyBurn-rate alerts; feature freeze when budget spentQuality debates decided by opinion instead of budget math
Delivery + churn telemetryDORA metrics (deploy freq, CFR, MTTR) + PR telemetry: size trend, review pickup time, 2-week churn (Apache DevLake, LinearB, Faros, DX)Not knowing whether any of the above is working
Scheduled debt workSonar new-debt reports feeding a standing 15–20% sprint budget; nightly knip/jscpd/audit reportsThe −74% legacy-maintenance collapse happening to you

If you only adopt ten things

The elite-team cut:

  1. TypeScript maximal strict + tsc --noEmit as a required check.
  2. typescript-eslint strict-type-checked with complexity budgets and error-masking bans, --max-warnings 0.
  3. Prettier/Biome + husky + lint-staged + commitlint.
  4. dependency-cruiser rules for your NestJS layers + no-cycle, as a required check.
  5. Global ValidationPipe (whitelist + forbid) and Zod-validated config.
  6. Agent hooks: post-edit lint/typecheck + a stop-gate running affected tests; deny-list for secrets, migrations, CI config.
  7. Diff coverage of 80% or more + Sonar quality gate on new code; Stryker on the money paths.
  8. PR size gate (~400 lines) + Danger metadata contract + CODEOWNERS + merge queue.
  9. Socket/dependency-review + frozen lockfile + Renovate with minimumReleaseAge.
  10. Feature flags + canary + OTel/Sentry tied to releases, with DORA + churn dashboards to verify it’s working.

Two honest caveats. Gates only work if they’re fast (parallelize CI, cache, affected-only runs) — slow gates get bypassed, and bypassed gates are worse than none. And no pipeline reviews intent: these guardrails buy your humans the time to do the one review only humans can do — “is this the right change?”

That is the same conclusion the data reaches in More code, less confidence: AI is an amplifier. This catalog is what “a strong engineering system” looks like when you write it down as configuration.

Further reading