Technical guardrails for LLM-assisted code: the full catalog
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