VerificationLesson
Linting
A type checker asks whether every value fits its type. Plenty of code that passes that question is still wrong or risky: a let that is never reassigned, an import written twice, a Vue template that injects raw HTML. A linter asks a different question, whether the code follows a set of rules about how it is written, and reports each place it doesn't.
Know ESLint already? Skip to In Terrarium.
Outside the box · General
The concept
ESLint parses each file into a syntax tree and runs its rules over it. A rule looks for one pattern, such as prefer-const for a let that could be const, and reports each match. Every rule has a severity: off, warn or error. Only errors change the exit code; a run with warnings and no errors exits with success, unless you pass --max-warnings. A comment such as // eslint-disable-next-line <rule> -- reason turns a rule off for one line, and the text after -- records why.
Rules come in plugins. typescript-eslint parses TypeScript and adds its own rules; it also turns off ESLint's core rules that TypeScript already covers, such as no-undef. eslint-plugin-vue checks .vue templates. A flat config, eslint.config.mjs, is an array of config objects; each may limit itself to some files with files, skip files with ignores, and later objects override earlier ones.
By default a rule sees one file's syntax and nothing else: no types, no other files. That is what makes linting fast, and why its rules differ from a type check's. typescript-eslint's typed linting gives rules the type checker's view, at the cost of building the whole TypeScript program first.
--cache stores each file's results and reuses them while the file and the config stay the same. The default metadata strategy compares a file's modification time and size; content compares a hash of its contents. The cache doesn't track dependencies between files, so a rule whose result depends on another file can report a stale result.
A Nuxt app needs a config that knows Nuxt's conventions: a page file may have a one-word name like index.vue, and composables are used without an import. The @nuxt/eslint module writes a project-aware config to .nuxt/eslint.config.mjs whenever Nuxt prepares the app, and your own eslint.config.mjs wraps it with withNuxt(). It turns on the recommended JavaScript, TypeScript and Vue rules plus Nuxt's own, and no formatting rules unless you opt in.
Full referenceESLint docs: Core Concepts
In Terrarium
pnpm lint in package.json runs ESLint over the whole repo, and the 's runs it on every change:
16 "lint": "eslint . --cache --cache-strategy content --cache-location node_modules/.cache/eslint/",
nuxt.config.ts lists @nuxt/eslint among its modules and sets none of its options: Terrarium lints with the module's defaults, with no formatting rules and no typed linting. The repo's whole config is eslint.config.mjs:
1// Flat config powered by @nuxt/eslint (project-aware rules from .nuxt).
2import withNuxt from './.nuxt/eslint.config.mjs'
3
4// A workflow script's top-level `return` is no valid module; a unit test checks it instead (#1870).
5export default withNuxt({ ignores: ['.claude/workflows/**', '.throwaway/**'] })
The file it imports doesn't exist in a fresh clone. nuxt prepare writes it, and pnpm install runs nuxt prepare after installing. The generated config lists the pages/, layouts/ and components/ folders of the app and of every 's layer, and turns vue/multi-word-component-names off for pages and layouts. It also lists every auto-imported name as a read-only global, for the plain JavaScript files where no-undef still runs. Beyond the two ignores above, it skips what .gitignore lists, plus public/ and build folders such as .nuxt/.
The scripts in .claude/workflows/ are skipped, and tests/unit/workflow-scripts.spec.ts compiles each one instead and checks that its meta.name matches its file name.
Where code breaks a rule on purpose, a disable comment says why. The Platform home page draws a Tinkerfund figure as raw SVG:
314 <!-- eslint-disable-next-line vue/no-v-html -- schema-checked SVG (app/utils/svg.ts, issue #1363) -->
315 <svg viewBox="0 0 400 300" aria-hidden="true" v-html="tinkerfund.campaign.figure" />
The reason points at app/utils/svg.ts, whose schema rejects SVG that could run script or fetch from another site before it reaches any page.
The cache lives under node_modules/, which git ignores. In CI, .github/actions/gate/action.yml restores it with actions/cache before the lint step, under a key built from the lockfile, eslint.config.* and the commit.
Why it's built this way
- A generated config, not a hand-kept one. Nuxt's conventions decide which files may have one-word names and which names need no import, and they change with every layer. Terrarium lets
@nuxt/eslintderive them from the app. The cost: lint depends on a generated file, which is stale after a new layer untilnuxt prepareruns again. eslint.config.mjs. - A cache keyed on content. A CI checkout gives every file a fresh modification time, so the default
metadatastrategy re-linted every file on each run. Hashing contents lets a restored cache skip unchanged files, and a warm run is several times faster than a cold one. The cost: the cache is only safe while every rule reads one file at a time. .github/actions/gate/action.yml. - A test, not lint, for workflow scripts. A workflow script ends with a top-level
return, which no ES module may hold, so ESLint can't parse it. Terrarium ignores the folder and checks each script with a unit test. The cost: no lint rule ever runs on them. tests/unit/workflow-scripts.spec.ts.
What to watch for
- A new layer needs
nuxt preparebeforepnpm lint.pnpm lintdoesn't run it, unlikepnpm test. Until it runs, the generated config doesn't know the new layer'sapp/pages/, and a page namedindex.vuethere failsvue/multi-word-component-names. Runpnpm exec nuxt prepare, orpnpm install, which runs it. - A warning passes the Gate. Many Vue rules,
vue/no-v-htmlamong them, are warnings, andpnpm lintsets no--max-warnings, so a warning prints butpnpm lintstill exits with success. Read its output, not only its exit status. - Lint doesn't see types.
no-undefand the other core rules TypeScript covers are off in.tsand.vuefiles, and no typed rule runs. A misspelt name there ispnpm typecheck's to catch (Type checking). - The cache assumes one-file rules. Turning on typed linting, or a rule that resolves imports across files, would let a cached result go stale when another file changes; the ESLint docs advise running without
--cachefor those rules. - Ignored by git means not linted. The
.throwaway/scripts and everything else in .gitignore are never linted; the.throwaway/**entry in eslint.config.mjs repeats what.gitignorealready does. ESLint here reads only JavaScript, TypeScript and Vue files: Markdown and YAML content is checked by Content validation instead.
Try it in your project
Pick a page file and a component, and run npx eslint --print-config <file> on each. Search the output for a rule you care about and read its severity: 1 is a warning, which won't fail CI. Then run npx eslint . --max-warnings 0 once; if it fails while your CI lint passes, decide for each warning whether to fix it, make its rule an error, or turn it off.
Where next
- LessonMerge gate
- LessonType checking
- LessonNuxt layers
- LessonContent validation
- LessonWorkflow scripts
Walkthroughs through this Lesson
- WalkthroughAsking an agent for a change
Last updated · Latest source on GitHub ↗