I manage about ten SvelteKit repositories deployed on Cloudflare Workers, and leveraged Anthropic's Claude Code to do it. Generally speaking, AI coding assistance can be fast and capable, especially if you already know how to code, but precisely because they are so fast, they can be — if you're not careful — consistently wrong in ways that are hard to spot.
Not wrong as in "the code doesn't work." Wrong as in: it uses .parse() instead of .safeParse(), it interpolates variables into D1 SQL strings instead of using .bind(), it fires off database mutations without checking the result, it nests four levels of async logic inside a load function that should have been split into helpers. The code works. It passes TypeScript. It even looks reasonable in a PR diff.
The problem is that if you add guidance to your CLAUDE.md file (or other coding agents' guide files) such as "always use safeParse()" and "never interpolate SQL", those are just suggestions, not constraints. The AI reads them, and might follow them, but also it might not. There is no compiler error when it forgets. There is no red squiggly line. The instruction is non-deterministic, so the compliance is non-deterministic too.
This post is about how I made compliance deterministic.
The Principle: Make Wrong Code Fail to Compile
The idea comes from engineering systems design. In hydraulics, 'backpressure' is resistance applied to a flow to regulate it — without backpressure, the system floods. In software, the equivalent is making bad patterns structurally impossible rather than merely discouraged.
A CLAUDE.md instruction like "always use safeParse()" is a sign on the pipe that says "please don't overflow." A lint rule that flags .parse() on Zod schemas is a pressure valve that rejects the bad flow automatically. The sign might be ignored; the valve cannot.
The goal is to migrate every "always" and "never" statement out of prose documentation and into something mechanical: a type, a lint rule, a test, or a structural pattern check. What remains in CLAUDE.md should be context and intent — the why, not the what.
The Verification Pyramid
The system has three layers, each catching progressively subtler issues. They chain into a single npm run verify command that runs after every code generation.
%%{init: {'flowchart': {'nodeSpacing': 20, 'rankSpacing': 30, 'padding': 6, 'curve': 'basis'}}}%%
flowchart TD
L1["Layer 1 — Types<br/>strict tsconfig · Zod schemas · discriminated unions"]
L2["Layer 2 — Linters<br/>oxlint ~50ms → ESLint Svelte rules → ast-grep structural"]
L3["Layer 3 — Tests<br/>vitest unit · Playwright E2E"]
D["npm run verify"]
L1 --> L2 --> L3 --> D
style L1 fill:#2980b9,color:#fff
style L2 fill:#3498db,color:#fff
style L3 fill:#7fb3d8,color:#fff
style D fill:#1e8449,color:#fff
Layer 1 is the TypeScript type system with strict: true and noUncheckedIndexedAccess. Zod schemas validate data at the boundary. Discriminated unions force exhaustive handling of user states. This layer is free — the compiler does the work.
Layer 2 is linting, split into three passes. This is where the interesting work happens, and where most of your typical "always/never" statements from CLAUDE.md land once they are mechanized.
Layer 3 is tests. Unit tests for utilities, contract tests for D1 behaviors, E2E for critical flows. Important, but not the focus of this post.
Why Three Linting Passes?
Each linting tool has a different strength. Using one for everything means either missing patterns or running slowly.
oxlint is a Rust-based linter that checks ~200 universal JavaScript/TypeScript rules in about 50 milliseconds. Correctness, suspicious patterns, performance anti-patterns. It's the metal detector at the airport: fast, catches the obvious stuff, processes everything in seconds.
ESLint with the Svelte plugin understands .svelte files as a whole — script, template, and style blocks together. It knows that a variable declared in <script> and used in {#each items} is not unused. It validates Svelte 5 rune semantics. And it hosts our custom backpressure rules:
| Rule | What it catches |
|---|---|
no-raw-html |
{@html expr} without sanitizeHtml() wrapper |
no-binding-leak |
Returning platform.env.* from load functions |
no-schema-parse |
.parse() instead of .safeParse() on Zod schemas |
no-silent-catch |
Empty catch {} blocks that swallow errors |
These four rules encode exactly the "always" and "never" statements that used to live only in CLAUDE.md. Now they produce red squiggly lines.
ast-grep is the newest addition and the most interesting. It uses tree-sitter parsers to match code by structure rather than by rule-specific visitor logic. Rules are declarative YAML:
id: n-plus-one-query-map
language: TypeScript
severity: warning
message: >-
Potential N+1 query: database call inside .map().
Use db.batch() or WHERE IN instead.
rule:
pattern: $ARR.map($$ARGS)
has:
pattern: $DB.prepare($$SQL)
stopBy: end
This catches something neither oxlint nor ESLint can express: a database query nested inside an array iteration. In a SvelteKit load function hitting Cloudflare D1, this is a performance disaster — one round trip per item instead of one batch query. The AI generates this pattern regularly because it looks correct and works fine on small datasets.
The full ast-grep rule set covers the D1/SQL antipatterns that matter most for Cloudflare Workers:
| Rule | What it catches |
|---|---|
sql-injection-d1 |
Template literals in db.prepare() |
sql-injection-concat |
String concatenation in db.prepare() |
n-plus-one-query-* |
DB calls inside .map(), .forEach(), for...of |
unbounded-query-all |
.all() without LIMIT in the SQL |
unchecked-db-run |
Fire-and-forget .run() without checking the result |
empty-catch-block |
Silent error swallowing |
Each of these corresponds to a mistake I found in AI-generated code that passed both oxlint and ESLint without complaint.
The "What's New" Problem
Mechanical enforcement handles known patterns. But Svelte and Cloudflare ship new features constantly — SvelteKit has had 50+ releases since 5.0. The AI doesn't know about match() for reverse route lookup (SvelteKit 2.52) or the latest D1 session API changes. Neither does your lint config.
So I built a complementary system: a "what's new" audit that checks each repo against upstream releases.
%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 35, 'padding': 6, 'curve': 'basis'}}}%%
flowchart TD
A["Svelte / SvelteKit<br/>releases"] --> B["Patterns feed<br/>svelte.cogley.jp"]
A2["Cloudflare<br/>changelog RSS"] --> D
B --> D{"audit-whatsnew.sh"}
D --> E["Scan 10 repos"]
E -->|"legacy patterns<br/>found in code"| F["Actionable<br/>report"]
E -->|"new features<br/>not in feed"| G["Update<br/>patterns feed"]
G --> B
style B fill:#ff3e00,color:#fff
style D fill:#1a5276,color:#fff
style F fill:#d4ac0d,color:#000
style G fill:#27ae60,color:#fff
It works in two directions:
Downstream: A shell script fetches the SvelteKit patterns feed (a JSON Feed 1.1 endpoint with grep-friendly search signatures, available on svelte.cogley.jp) and the Cloudflare changelog RSS. It filters the Cloudflare entries to only the products each repo actually uses (by reading wrangler.jsonc bindings). Then it searches each repo's source code for legacy patterns from the feed. The output is a concrete report: "repo x has 3 files still using writable() stores — $state class replacement available since Svelte 5.29."
Upstream: When the audit discovers Svelte features from recent releases that aren't yet in the patterns feed, it flags those as "feed gaps." This closes the loop — the feed stays current because the audit tells us when it's fallen behind.
The audit runs in about 10 seconds across all ten repos. It is a shell script, not an AI call. The intelligence is in the feed's search_signatures — the script just greps.
Distribution: One Repo to Rule Them All
All of this — the lint configs, the ast-grep rules, the audit script, the Claude commands and rules, and the GitHub Actions workflows — lives in a single .github repository. A TypeScript sync script distributes everything to the ten consumer repos:
esolia.github (source of truth, my github org's profile repo)
├── scripts/ast-grep-rules/*.yml → scripts/shared/ast-grep-rules/
├── scripts/audit-whatsnew.sh → scripts/shared/audit-whatsnew.sh
├── .claude/shared-rules/*.md → .claude/rules/
├── .claude/shared-commands/*.md → .claude/commands/
└── .github/shared-caller-workflows → .github/workflows/
One sync-all.sh run updates everything. Consumer repos don't maintain their own lint configs or rule files — they inherit them. This matters because the most common failure mode in multi-repo setups is drift: one repo gets the fix, the others don't.
What This Catches
Since deploying this system, the ast-grep rules alone have flagged:
- Template literal SQL injection in load functions that the AI generated when asked to "add a search filter"
- N+1 query patterns in
{#each}block server-side rendering where the AI helpfully awaited each item individually - Unbounded
.all()queries that worked perfectly in development (5 rows) and would have timed out in production (50,000 rows) - Empty catch blocks where the AI wrapped a D1 call in try/catch but forgot to actually handle the error
None of these produced TypeScript errors. None were caught by oxlint or ESLint. All of them would have shipped to production on the next git push without the structural layer.
The Remaining Gap
This system is not complete. The backpressure guide describes two rules I haven't implemented yet: no-raw-db-prepare (enforcing tenant isolation via a query helper) and no-plain-error-throw (enforcing classified errors). These require structural changes to the codebase — a tenant context type, an error classification module — before the lint rules can enforce them.
The pattern is always the same: build the mechanism first (the type, the helper, the abstraction), then add the enforcement that makes it mandatory. Enforcement without mechanism is just a more annoying form of documentation.
Takeaways
If you're running AI coding assistants across multiple repositories:
Audit your
CLAUDE.mdor other guidance files for "always" and "never" statements. Each one is a candidate for mechanical enforcement. If you can express it as a type constraint, lint rule, or structural check, do that then remove the prose.Linters have different strengths. oxlint is fast but shallow. ESLint understands frameworks. ast-grep matches structure. Using all three at 50ms + 2s + 200ms is still faster than one human review.
Track upstream releases programmatically. The AI can't use features it doesn't know about. A structured feed of "what's new" with search signatures turns "go check what's new" from a vague request into a deterministic scan.
Centralize, then distribute. Maintaining lint configs in ten repos means ten places for drift. Maintaining them in one repo and syncing means one place to update and zero drift.
The AI writes code faster than you can review it. The answer isn't slower AI or more reviewers. It's making the codebase reject bad patterns the same way a type system rejects bad types — automatically, immediately, and without judgment.
The systems described here are specific to SvelteKit on Cloudflare Workers, but the principle — mechanical enforcement over written instructions — applies to any AI-assisted codebase. My svelte patterns feed is public if you want to point your own tooling at it.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.