A deterministic quality gate for Nuxt 4 apps, built for teams that ship code with AI agents.
- Architecture rules: a 5-layer component model (Page → Orchestrator → Operator → Presenter → Composable), enforced by ESLint, so smart components can't quietly grow into monoliths.
- Guard and locality rules: early returns, named conditions,
app↛serverimports, awaitednavigateTo, auth gates in middleware, noprovide/inject. - A gate the agent can't switch off:
@ts-ignoreandeslint-disableof harness rules fail the gate. - Fast mode for agents: lints changed files in ~1 s, without the app's
node_modulesor.nuxt/. - Claude Code hooks: feedback after every edit, and a turn can't end while changed files fail.
- Ratchets for legacy code: existing violations are recorded once; the count can only go down.
Every finding explains itself: ❌ what → 💡 why → 🛠 fix → 📖 doc.
@edumapper/nuxt-harness 0.6.0 — fast gate, 3 files
→ ESLint (harness rules).......... FAIL 412ms
✗ app/components/ProductCard.vue:4 [arch/no-smart-calls] ❌ useRouter() — navigation in a Presenter
💡 A Presenter doesn't decide where to go. It emits an event,
and the Operator (Op*.vue) or Orchestrator (Orc*.vue) decides what to do.
🛠 Replace with: emit('select', item)
Try it on any Nuxt 4 repository, with nothing installed:
npx --package github:edumapper/nuxt-harness#v0.6.0 nuxt-harness fast --allThen adopt it:
npm i -D github:edumapper/nuxt-harness#v0.6.0 # or pnpm / yarn / bun add -d// eslint.config.mjs (with @nuxt/eslint)
import { harness, typeAware } from '@edumapper/nuxt-harness/eslint'
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(...harness(), ...typeAware(import.meta.dirname))Add .harness/ to .gitignore. On an existing app, record current violations once — after
that, the counts can only go down:
npx eslint . --suppress-all # → eslint-suppressions.json (read by eslint and `fast`)
npx nuxt-harness full --update-baseline # → nuxt-harness-baseline.json (static checks + CRAP)Requirements: Node ≥ 22, a git repository, Nuxt 4 (the app/ layout, or srcDir: '.').
Nuxt layers under layers/ are covered too.
The lint toolchain is a peer dependency, so the harness uses your project's versions and never
installs its own copies: ESLint ^9.24 || ^10, eslint-plugin-vue ^10, vue-eslint-parser
^10 and @typescript-eslint/* ^8.12. @nuxt/eslint already brings them; otherwise npm,
pnpm and bun install the missing peers for you (the no-install npx/bunx mode included).
nuxt-harness fast [files…] [--all] [--json] # changed files (vs origin/HEAD merge-base + untracked)
nuxt-harness full [--update-baseline] [--json] # every file + the app's toolchain; needs the app installed
nuxt-harness hook # Claude Code PostToolUse: gate the edited file
nuxt-harness stop # Claude Code Stop: the turn can't end while changed files fail| Check | fast | full |
|---|---|---|
Import hygiene, escape hatches, console, secrets, unvalidated readBody, i18n keys, TODO (warn) |
✓ | ✓ |
| Harness ESLint rules (layers, locality, guards, Vue contracts, complexity, opt-in design system) | ✓ | ✓ |
nuxt typecheck, the app's ESLint config (type-aware), knip, cspell, jscpd, vitest + coverage |
✓ | |
CRAP ≤ 30 per .ts function |
✓ |
full runs the optional tools only when the app has configured them (knip.json,
cspell.json, .jscpd.json, vitest.config.*), through the app's package manager. Both
commands write .harness/report.json for agents and CI. Set NUXT_HARNESS_BASE to compare
fast against another branch than origin/HEAD.
None is required. Options cover what depends on your stack — put them in
nuxt-harness.config.mjs at the repo root:
/** @type {import('@edumapper/nuxt-harness/eslint').HarnessOptions} */
export default {
i18n: true, // default: on when @nuxtjs/i18n is installed
authComposables: ['useUserSession'], // checking these + redirecting belongs in middleware
dataComposables: ['useApiQuery'], // your data-layer wrappers, banned in Presenters
designSystem: { // opt-in; the harness ships no design tokens
palettes: ['zinc', 'red', 'green']
}
}The design-system rules (hardcoded colors, allowed Tailwind palettes, nested bordered boxes) are off until you enable them. See configuration for every option and how to share tokens from a package.
// .claude/settings.json
{
"hooks": {
// after each edit: feedback lands before the agent moves on
"PostToolUse": [{ "matcher": "Edit|Write|MultiEdit", "hooks": [
{ "type": "command", "command": "node_modules/.bin/nuxt-harness hook", "timeout": 60 }
] }],
// end of turn: every file changed on the branch (Bash edits included) must pass;
// blocks at most 3 times in a row per session, then warns the user instead
"Stop": [{ "hooks": [
{ "type": "command", "command": "node_modules/.bin/nuxt-harness stop", "timeout": 120 }
] }]
}
}The package ships an agent skill in skills/nuxt-harness/: the
architecture, the rules, and lint-clean reference implementations of each layer. Link it from
node_modules/@edumapper/nuxt-harness/skills/nuxt-harness into .claude/skills/ (or your
agent's skills directory) so it always matches the installed version.
Other agents can use the same gate: run nuxt-harness fast after each edit and read
.harness/report.json.
| File | Layer | Data access | Stores | Navigation |
|---|---|---|---|---|
app/pages/**/*.vue |
Page — assembles Orc/Op | — | read | ✓ |
app/components/**/Orc*.vue |
Orchestrator — the feature's brain | via composables | ✓ | ✓ |
app/components/**/Op*.vue |
Operator — wires Presenters | composables only | ✓ | ✓ |
app/components/**/*.vue |
Presenter — props in, events out | ✕ | ✕ | ✕ |
app/composables/use*.ts |
Composable — reusable logic | ✓ | ✓ | — |
- Architecture: the model, its rules and an audit method
- Rules: every rule and why it exists
- Gold standards: reference code for each layer
- Example app: a minimal Nuxt 4 app that passes the gate
Issues and pull requests are welcome — see CONTRIBUTING.md. The source is plain ESM JavaScript with JSDoc types: no build step.
bun install
bun run test