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.
| Measure | Tooling | What it prevents |
|---|---|---|
| Repo context files | AGENTS.md / CLAUDE.md / .cursor/rules / .github/copilot-instructions.md | Model inventing conventions: naming, layer rules, banned patterns, “always use our @app/http-client, never raw axios” |
| Machine-readable conventions | docs/adr/ (ADRs), CONTRIBUTING.md, example modules as templates | Architecture drift; the agent copies the golden path instead of imagining one |
| Scaffolding by generator, not freeform | Nest CLI schematics, Nx generators, Hygen/Plop templates | Structural inconsistency: every module gets identical shape (controller/service/repo/dto/spec) |
| Pinned toolchain | Volta / 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 agent | Monorepo path aliases, published typedocs, MCP servers over internal docs | Duplication: the model reuses @app/* packages instead of re-implementing them (the GitClear failure mode) |
| Editor-integrated feedback | ESLint/tsc/Prettier on save in IDE and in the agent loop | Errors 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.
| Measure | Config | What it prevents |
|---|---|---|
| Full strict mode | "strict": true plus noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch, noPropertyAccessFromIndexSignature | Whole 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 metric | type-coverage with a ratchet (never below current %) | Gradual any erosion across hundreds of small AI commits |
| Typecheck as a standalone gate | tsc --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.
| Concern | Rules / tools | What it prevents |
|---|---|---|
| Complexity budgets | complexity (cyclomatic), sonarjs/cognitive-complexity, max-lines, max-lines-per-function, max-depth, max-params | 300-line generated functions nobody can review |
| Duplication | jscpd in CI with threshold (e.g. fail above 3% new duplication), SonarQube duplication on new code | The +81% copy/paste trend, directly |
| Error masking | no-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-syntax | The +47% rise in constructs that swallow failures — and unhandled rejections killing a Nest process |
| Dead code | knip (unused files, exports, dependencies), no-unused-vars, unused-imports | Orphan helpers the agent generated “just in case” |
| Import discipline | eslint-plugin-import (no-cycle, no-extraneous-dependencies, order), no-restricted-imports | Undeclared deps, tangled import graphs, reaching into other modules’ internals |
| Pattern bans | no-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 repositories | The model bypassing your abstractions because it doesn’t know they’re mandatory |
| Zero-diff formatting | Prettier or Biome, enforced in hook + CI | Formatting noise inflating AI diffs and hiding real changes in review |
| Naming and API shape | @typescript-eslint/naming-convention, explicit-function-return-type on exported APIs | Inconsistent public surfaces across generated modules |
| Custom org rules | Local 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)' } },
],
};| Measure | Tooling | What it prevents |
|---|---|---|
| Dependency rules + orphan/circular detection | dependency-cruiser (graph output doubles as living architecture doc) | Controllers→DB shortcuts, cross-domain reach-ins, cycles |
| Circular imports (fast check) | madge --circular src | Nest DI failures (forwardRef creep) and unloadable module graphs |
| Tagged module boundaries in monorepos | Nx @nx/enforce-module-boundaries with tags (type:feature, type:data-access, scope:billing) or eslint-plugin-boundaries | scope:billing importing scope:orders internals; util libs importing features |
| Architecture as unit tests | ts-arch (“classes in domain/ depend only on domain/“) | Regressions in rules too nuanced for path globs |
| Nest module encapsulation | Minimal exports per module; barrel-file discipline; forbid @Global() except allow-listed | ”Everything is public so everything gets coupled” |
| DI discipline | Ban new SomeService() in app code via no-restricted-syntax; constructor injection only | The 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.
| Measure | Tooling | What it prevents |
|---|---|---|
| Runtime validation of every input | Nest ValidationPipe with whitelist, forbidNonWhitelisted and transform enabled globally + class-validator DTOs, or nestjs-zod | Unvalidated fields flowing in because the model trusted the client |
| Typed, validated config | @nestjs/config + Zod/Joi schema on startup — fail fast on missing env | Hallucinated env vars discovered in production |
| API contract lint + breaking-change gate | @nestjs/swagger generated spec + Spectral lint + oasdiff/openapi-diff vs main in CI | Silent breaking changes to consumers inside a big generated diff |
| Consumer contract tests | Pact (if you have internal consumers) | “It compiles here” breaking another team |
| Serialization control | ClassSerializerInterceptor + explicit @Expose/@Exclude | Entity fields (password hashes, internal ids) leaking into responses |
| Migration gates | Migrations only (never synchronize: true); prisma migrate diff / TypeORM schema:log clean check; squawk lint for destructive Postgres DDL; require down-migrations | The 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.
| Measure | Tooling | What it prevents |
|---|---|---|
| Staged-files lint + format + related tests | husky + lint-staged (ESLint --fix, Prettier, optionally vitest related --run) | Broken style/lint ever entering history |
| Full typecheck + affected tests on push | pre-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 scanning | gitleaks or trufflehog as pre-commit + CI backstop | API keys the model copied from context into code |
| Commit message contract | commitlint + Conventional Commits (feat(billing): ...) | Unreadable history; enables semantic-release and scope-based review routing |
| Commit size guard | Custom hook or Danger local run warning/failing above ~300–400 changed LOC | The 2.5x oversized-diff habit, stopped at the source |
| Branch hygiene | Hook 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 CI | Guardrails 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.
| Measure | Tooling | What it prevents |
|---|---|---|
| Post-edit hooks | Claude Code hooks (PostToolUse → run eslint --fix + tsc --noEmit on touched files); equivalent hooks in Cursor and other agents | The agent accumulating 40 broken files before anyone notices |
| ”Done” gate | Stop-hook that runs lint + typecheck + affected tests and refuses completion until green | ”I’m finished” with failing tests — the agent must prove done |
| Scope confinement | Agent permission config: deny-list .env*, migrations/, .github/workflows/, lockfiles, auth/ + payment paths; allow-list working dirs | The model “helpfully” editing CI, secrets, or the money path |
| Plan-before-apply | Require plan/diff approval for changes touching more than N files or protected paths | Sprawling refactors nobody asked for |
| Sandboxed execution | Devcontainer/VM: no prod credentials, egress-restricted network, disposable filesystem | Prompt-injected or hallucinated commands reaching real systems |
| Independent verifier pass | Second model/session prompted to refute the change (“find why this is wrong”) before PR | Author-bias: the same context that wrote the bug approving it |
| Tests move with code | Agent instructions + Danger rule: src/** changes without *.spec.ts changes fail | Generated code with zero or assertion-free tests |
| Provenance | Commit trailer / PR label AI-assisted: yes + session log attached | Reviewers 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
| Measure | Tooling | What it prevents |
|---|---|---|
| Build + typecheck + lint (zero warnings) | tsc --noEmit, ESLint --max-warnings 0 | Baseline 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 modules | Stryker (nightly or on core/, billing/, auth/ paths) | AI-written tests that execute code but assert nothing |
| Real-infra integration tests | Testcontainers (Postgres/Redis/Kafka) + supertest e2e | Code that passes against mocks and dies against reality |
| Flaky-test quarantine | Retry-with-report, quarantine tag, flake dashboard | Red-is-normal culture that trains everyone to ignore CI |
| Quality gate on new code | SonarQube/SonarCloud: new duplication under 3%, new issues at zero, maintainability rating on diff | Debt entering silently commit by commit |
| Duplication delta | jscpd gate vs main | Copy/paste growth (the GitClear +81%) |
| PR size gate | Danger.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 contract | Danger: linked ticket, description, risk note, test evidence | Context-free diffs that force reviewers to reverse-engineer intent |
| API breaking-change check | oasdiff vs main (from Layer 4) | Accidental contract breaks |
Security and supply chain
LLMs made this layer non-optional.
| Measure | Tooling | What it prevents |
|---|---|---|
| SAST | CodeQL and/or Semgrep (+ custom org rules) | Injection, path traversal, authz mistakes in generated code |
| Dependency vetting | Socket.dev / GitHub dependency review: block new deps younger than N days, typosquats, install scripts, no repo | Slopsquatting — attackers pre-registering package names LLMs hallucinate |
| New-dependency approval | CI check: any new entry in package.json requires a labeled approval | The model adding left-pad-utils-2 because it “seemed to exist” |
| Lockfile integrity | npm 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 scan | osv-scanner / npm audit signals via Renovate; license-checker gate | Known CVEs; GPL surprises in a commercial codebase |
| Controlled updates | Renovate with minimumReleaseAge (e.g. 7–14 days), grouped PRs, automerge patch-only when green | Fresh-malicious-version attacks; agents bumping deps ad hoc |
| Secret scan (server-side) | gitleaks in CI + GitHub push protection | Keys that slipped past local hooks |
| SBOM + provenance | Syft SBOM, npm provenance/SLSA attestations on your own publishes | Unanswerable “are we affected?” moments |
Repo policy
| Measure | Tooling | What it prevents |
|---|---|---|
| Branch protection | Required checks, at least one human review, dismiss stale approvals, no force-push, linear history | Merging around the gates |
| CODEOWNERS on critical paths | auth/, billing/, migrations/, .github/ → mandatory named reviewers | High-blast-radius changes reviewed by whoever was fastest |
| No self-approval for bot PRs | Rule: agent-authored PRs cannot be approved by their invoker only | Rubber-stamping your own agent |
| Merge queue | GitHub merge queue / Mergify | Semantically conflicting PRs that are individually green breaking main |
| AI first-pass review | CodeRabbit / Greptile / Copilot code review / Claude as reviewer — advisory, never replacing the human gate | Reviewer fatigue on mechanical issues; frees humans for design and intent |
Layer 8 — Post-merge and runtime (assume something got through)
| Measure | Tooling | What it prevents |
|---|---|---|
| Feature flags + kill switches | OpenFeature + Unleash/Flagsmith; new AI-touched paths ship dark | Rollback requiring a redeploy at 3 a.m. |
| Progressive delivery | Canary/blue-green (Argo Rollouts), auto-rollback on error/latency burn | Full-blast exposure of a defect to 100% of traffic |
| Observability contract | OpenTelemetry + 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 policy | Burn-rate alerts; feature freeze when budget spent | Quality debates decided by opinion instead of budget math |
| Delivery + churn telemetry | DORA 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 work | Sonar new-debt reports feeding a standing 15–20% sprint budget; nightly knip/jscpd/audit reports | The −74% legacy-maintenance collapse happening to you |
If you only adopt ten things
The elite-team cut:
- TypeScript maximal strict +
tsc --noEmitas a required check. - typescript-eslint
strict-type-checkedwith complexity budgets and error-masking bans,--max-warnings 0. - Prettier/Biome + husky + lint-staged + commitlint.
- dependency-cruiser rules for your NestJS layers +
no-cycle, as a required check. - Global
ValidationPipe(whitelist + forbid) and Zod-validated config. - Agent hooks: post-edit lint/typecheck + a stop-gate running affected tests; deny-list for secrets, migrations, CI config.
- Diff coverage of 80% or more + Sonar quality gate on new code; Stryker on the money paths.
- PR size gate (~400 lines) + Danger metadata contract + CODEOWNERS + merge queue.
- Socket/dependency-review + frozen lockfile + Renovate with
minimumReleaseAge. - 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
- Agent hooks: deterministic guardrails for AI-generated code
- Repository guardrails for AI-generated code
- Git hooks are your best defense against AI-generated mess
- GenAI-based development platform, part 1: guardrails (microservices.io)
- When AI writes the code, build guardrails instead of reviewing every line
- CSA research note — Slopsquatting: AI hallucinations fuel supply-chain attacks
- Socket — 53 slopsquatting targets across 5 frontier LLMs
- Sonar — quality gates at the PR level
- LinearB — 8M PRs: where productivity breaks down
- Lab34 — More code, less confidence: where quality breaks in agile teams