Code Quality: Pre-Commit Hooks and Automated Linting
Quality gates that run automatically.
The Quality Problem
Code quality degrades without enforcement:
- Inconsistent formatting across the codebase
- Linting errors pile up until they're overwhelming
- Type errors slip through to production
- Code reviews catch preventable issues
- Technical debt accumulates silently
Fabrk prevents this with automated checks that run on every commit.
The Pre-Commit Stack
Every commit automatically runs:
| Check | Tool | Purpose | |-------|------|---------| | Type checking | TypeScript | Catch type errors before commit | | Linting | ESLint | Enforce code patterns, auto-fix issues | | Formatting | Prettier | Consistent code style |
If any check fails, the commit is blocked until fixed.
How It Works
Fabrk uses Husky for git hooks and lint-staged for efficient checking:
git commit -m "Add feature"
│
▼
┌──────────────────────┐
│ Husky Pre-Commit │
│ Hook │
└──────────────────────┘
│
▼
┌──────────────────────┐
│ TypeScript Check │◄── Checks entire project
│ (npm run type-check)│
└──────────────────────┘
│
▼
┌──────────────────────┐
│ lint-staged │◄── Only staged files
│ ESLint + Prettier │
└──────────────────────┘
│
▼
Commit succeeds
or fails with errors
Configuration Files
Husky Setup
$# .husky/pre-commit$#!/usr/bin/env sh$. "$(dirname -- "$0")/_/husky.sh"$# Run TypeScript type checking$npm run type-check$# Run lint-staged on staged files$npx lint-staged
lint-staged Configuration
// package.json{"lint-staged": {"*.{ts,tsx}": ["eslint --fix --max-warnings=0","prettier --write"],"*.{js,jsx,mjs,cjs}": ["eslint --fix --max-warnings=0","prettier --write"],"*.{json,md,mdx,yml,yaml}": ["prettier --write"],"*.css": ["prettier --write"]}}
Only staged files are checked, keeping commits fast even in large codebases.
TypeScript Checking
Every commit verifies the entire project's types:
$# Runs automatically on commit$npm run type-check
What It Catches
// Missing typesconst user = getUser(id); // Error: 'id' is not defined// Type mismatchesfunction greet(name: string) {return `Hello, ${name}`;}greet(123); // Error: Argument of type 'number' is not assignable// Missing return typesfunction getData() { // Warning: Missing return typereturn fetch('/api/data');}// Unused variablesconst unused = 'value'; // Warning: 'unused' is declared but never used// Import errorsimport { NonExistent } from './module'; // Error: Module has no exported member
TypeScript Configuration
// tsconfig.json{"compilerOptions": {"strict": true,"noUnusedLocals": true,"noUnusedParameters": true,"noImplicitReturns": true,"noFallthroughCasesInSwitch": true,"noUncheckedIndexedAccess": true,"exactOptionalPropertyTypes": true}}
ESLint Configuration
Fabrk uses ESLint flat config (the modern approach):
// eslint.config.mjsimport js from '@eslint/js';import tseslint from 'typescript-eslint';import react from 'eslint-plugin-react';import reactHooks from 'eslint-plugin-react-hooks';import importPlugin from 'eslint-plugin-import';export default tseslint.config(js.configs.recommended,...tseslint.configs.recommended,...tseslint.configs.strictTypeChecked,{plugins: {react,'react-hooks': reactHooks,import: importPlugin,},rules: {// React Hooks rules'react-hooks/rules-of-hooks': 'error','react-hooks/exhaustive-deps': 'warn',// TypeScript specific'@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_',varsIgnorePattern: '^_',}],'@typescript-eslint/no-explicit-any': 'warn','@typescript-eslint/prefer-nullish-coalescing': 'error','@typescript-eslint/prefer-optional-chain': 'error',// Import organization'import/order': ['error', {groups: ['builtin','external','internal',['parent', 'sibling'],'index',],'newlines-between': 'always',alphabetize: { order: 'asc' },}],// General best practices'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn','prefer-const': 'error','no-var': 'error',},},{// Test files have relaxed rulesfiles: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts'],rules: {'@typescript-eslint/no-explicit-any': 'off','@typescript-eslint/no-non-null-assertion': 'off',},},{// Ignore patternsignores: ['node_modules/**','.next/**','dist/**','coverage/**',],});
Custom Rules for Design System
// Additional rules to enforce design system{rules: {// Ban hardcoded colors'no-restricted-syntax': ['error',{selector: 'Literal[value=/bg-white|bg-black|bg-gray-|text-gray-|text-white|text-black/]',message: 'Use design tokens instead of hardcoded colors (e.g., bg-background, text-foreground)',},{selector: 'Literal[value=/#[0-9a-fA-F]{3,8}/]',message: 'Use CSS variables instead of hex colors',},],},}
Prettier Configuration
Consistent formatting across the entire codebase:
// .prettierrc{"semi": true,"singleQuote": true,"tabWidth": 2,"trailingComma": "es5","printWidth": 100,"bracketSpacing": true,"arrowParens": "always","endOfLine": "lf","plugins": ["prettier-plugin-tailwindcss"]}
Prettier Ignore
# .prettierignorenode_modules.nextdistcoveragepnpm-lock.yamlpackage-lock.json*.min.js
Tailwind Class Sorting
The prettier-plugin-tailwindcss plugin automatically sorts Tailwind classes:
// Before Prettier<div className="p-4 flex bg-card items-center border rounded-lg gap-2 text-sm" />// After Prettier (sorted by Tailwind conventions)<div className="flex items-center gap-2 rounded-lg border bg-card p-4 text-sm" />
Running Checks Manually
$# Run all linting$npm run lint$# Run linting with auto-fix$npm run lint -- --fix$# Run formatting check$npm run format:check$# Run formatting with write$npm run format$# Run TypeScript check$npm run type-check$# Run all validation (like pre-commit does)$npm run validate
Package.json Scripts
{"scripts": {"lint": "eslint . --max-warnings=0","lint:fix": "eslint . --fix --max-warnings=0","format": "prettier --write .","format:check": "prettier --check .","type-check": "tsc --noEmit","validate": "npm run type-check && npm run lint && npm run format:check"}}
Bypassing Hooks
For emergencies only:
$git commit --no-verify -m "Emergency fix"
When to use --no-verify:
- Hotfix that needs to go out immediately
- Temporary WIP commit on a feature branch
- Build system is broken and you need to push a fix
When NOT to use it:
- "I'll fix the errors later" (you won't)
- "It's just a small change" (small changes can break things)
- "The linter is wrong" (configure the rule instead)
Skipped checks mean skipped quality. Use sparingly.
CI Integration
The same checks run in CI, ensuring nothing bypassed locally reaches main:
# .github/workflows/ci.ymlname: CIon:push:branches: [main]pull_request:branches: [main]jobs:quality:runs-on: ubuntu-lateststeps:- name: Checkoutuses: actions/checkout@v4- name: Setup Node.jsuses: actions/setup-node@v4with:node-version: '22'cache: 'npm'- name: Install dependenciesrun: npm ci- name: Type checkrun: npm run type-check- name: Lintrun: npm run lint- name: Format checkrun: npm run format:check- name: Buildrun: npm run buildtest:runs-on: ubuntu-latestneeds: qualitysteps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with:node-version: '22'cache: 'npm'- run: npm ci- run: npm test
Branch Protection
Configure GitHub branch protection:
- Go to Settings > Branches > Add rule
- Branch name pattern:
main - Enable:
- Require status checks to pass
- Require branches to be up to date
- Select:
quality,test
Now PRs can't be merged without passing all checks.
VS Code Integration
Fabrk includes VS Code settings for real-time feedback:
// .vscode/settings.json{"editor.formatOnSave": true,"editor.defaultFormatter": "esbenp.prettier-vscode","editor.codeActionsOnSave": {"source.fixAll.eslint": "explicit","source.organizeImports": "explicit"},"typescript.tsdk": "node_modules/typescript/lib","typescript.enablePromptUseWorkspaceTsdk": true,"[typescript]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},"[typescriptreact]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},"eslint.validate": ["javascript","javascriptreact","typescript","typescriptreact"]}
Recommended Extensions
// .vscode/extensions.json{"recommendations": ["esbenp.prettier-vscode","dbaeumer.vscode-eslint","bradlc.vscode-tailwindcss","prisma.prisma"]}
With these settings, files are formatted and linted on save, catching issues before you even try to commit.
Design System Linting
Fabrk includes a custom lint script for design system compliance:
$npm run design:lint
What It Checks
// scripts/design-lint.tsconst VIOLATIONS = {// Hardcoded colorshardcodedColors: ['bg-white', 'bg-black', 'text-white', 'text-black','bg-gray-', 'text-gray-', 'border-gray-','bg-red-', 'bg-green-', 'bg-blue-',],// Missing mode.radius on bordered elementsmissingRadius: /className="[^"]*border[^"]*"(?![^"]*mode\.radius)/,// Arbitrary values instead of design tokensarbitraryValues: ['p-[', 'm-[', 'w-[', 'h-[', 'gap-[','text-[#', 'bg-[#', 'border-[#',],};// Reports violations with file and line number// Exits with error code if violations found
Running the Linter
$# Check entire src directory$npm run design:lint$# Check specific directory$npm run design:lint src/app/$# Check specific file$npm run design:lint src/components/dashboard/stats-card.tsx
The Commit Flow
Here's exactly what happens when you commit:
-
Stage changes
$git add src/components/new-feature.tsx -
Commit
$git commit -m "Add new feature component" -
Husky triggers pre-commit hook
-
TypeScript check runs on entire project
✓ Type checking passed (2.3s) -
lint-staged runs on staged files:
✔ Preparing lint-staged... ✔ Running tasks for staged files... ✔ src/components/new-feature.tsx — 1 file ✔ eslint --fix --max-warnings=0 ✔ prettier --write ✔ Applying modifications from tasks... ✔ Cleaning up temporary files... -
If all pass → commit succeeds
[main abc1234] Add new feature component 1 file changed, 45 insertions(+) create mode 100644 src/components/new-feature.tsx -
If any fail → commit blocked with errors
✖ eslint --fix --max-warnings=0: /src/components/new-feature.tsx 12:5 error 'unused' is defined but never used @typescript-eslint/no-unused-vars husky - pre-commit hook exited with code 1 (error)
Error Recovery
When a commit fails, here's how to fix it:
See What Failed
$# Run the same checks manually$npm run lint$npm run type-check
Auto-Fix What Can Be Fixed
$# ESLint auto-fix$npm run lint -- --fix$# Prettier auto-fix$npm run format
Fix Remaining Issues Manually
// Before: unused variableconst unused = 'value';// After: remove it// (or prefix with _ if intentionally unused)const _intentionallyUnused = 'value';
Commit Again
$git add .$git commit -m "Add new feature component"
Performance
lint-staged only checks staged files, keeping commits fast:
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ src/app/page.tsx — 1 file
✔ eslint --fix (892ms)
✔ prettier --write (234ms)
✔ Applying modifications from tasks...
Total time: 1.4s
Compare to checking the entire codebase:
- Full lint: ~15-30 seconds
- Full type-check: ~5-10 seconds
- lint-staged: ~1-3 seconds
Adding Custom Rules
ESLint Custom Rule
// eslint.config.mjs{rules: {// Enforce import order'import/order': ['error', {groups: ['builtin', 'external', 'internal'],'newlines-between': 'always',}],// Ban console.log in production code'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',// Require explicit return types on functions'@typescript-eslint/explicit-function-return-type': ['warn', {allowExpressions: true,allowHigherOrderFunctions: true,}],// Enforce naming conventions'@typescript-eslint/naming-convention': ['error',{ selector: 'interface', format: ['PascalCase'] },{ selector: 'typeAlias', format: ['PascalCase'] },{ selector: 'enum', format: ['PascalCase'] },],},}
Creating a Custom ESLint Plugin
// eslint-plugins/design-system.jsmodule.exports = {rules: {'no-hardcoded-colors': {create(context) {return {Literal(node) {if (typeof node.value === 'string') {if (/bg-white|text-gray-/.test(node.value)) {context.report({node,message: 'Use design tokens instead of hardcoded colors',});}}},};},},},};
Best Practices
- Never skip hooks - Fix the issue, don't bypass
- Run checks locally - Before pushing, run
npm run validate - Keep rules consistent - Same in dev, CI, and production
- Auto-fix when possible - ESLint and Prettier handle most issues
- Document exceptions - If you disable a rule, explain why with a comment
- Review new rules carefully - Add them gradually to avoid overwhelming the team
- Keep the feedback loop fast - If checks take too long, people will bypass them
Troubleshooting
Hooks Not Running
$# Reinstall husky$npx husky install$# Make hooks executable$chmod +x .husky/pre-commit
ESLint Cache Issues
$# Clear ESLint cache$rm -rf node_modules/.cache/eslint$# Run lint with no cache$npm run lint -- --no-cache
TypeScript Errors After Update
$# Regenerate Prisma types$npx prisma generate$# Clear TypeScript cache$rm -rf node_modules/.cache/typescript$# Restart TS server in VS Code$Cmd+Shift+P > "TypeScript: Restart TS Server"
Getting Started
Hooks are set up automatically when you run npm install.
If hooks aren't running after a fresh clone:
$npm run prepare$# or$npx husky install
Quality enforcement, built in.