The linter that checks you handled the sad path, not just the happy path.
Every async boundary in a UI can be loading, error, empty, offline,
unauthorized, loadingMore… but developers — and AI codegen especially —
render only success and ship the rest as blank screens, infinite spinners and
white-screen crashes.
Other tools lint how you call your data (@tanstack/eslint-plugin-query), or
render the states for you if you adopt their component. sadpath is the
diagnosis: it statically audits arbitrary components — including the ones your
agent wrote — and tells you which states you forgot.
✗ ProjectList examples/bad.tsx:3
✗ useQuery → manque loading, error, empty
State coverage 0%
| Package | What it is |
|---|---|
sadpath-core |
The analysis engine (AST + adapters). Framework-agnostic. |
eslint-plugin-sadpath |
The main product: ESLint rules, in your editor and CI. |
sadpath |
CLI: a state coverage report + badge over your repo. |
sadpath-states |
Headless Skeleton / EmptyState / ErrorState — the fixes sadpath scaffolds. |
npm i -D eslint-plugin-sadpath # ESLint plugin (editor + CI)
npm i -D sadpath # CLI: npx sadpath# no install needed — audit any folder
npx sadpath src
# or, from a clone of this repo:
pnpm install && pnpm build
node packages/cli/dist/index.js examples// eslint.config.js
import sadpath from 'eslint-plugin-sadpath';
export default [sadpath.configs.recommended];npx sadpath src # report per file + overall state coverage
npx sadpath src --min 90 # exit 1 if coverage < 90 % (CI gate)
npx sadpath src --json # machine-readableFor each data-hook boundary (useQuery, useSWR, custom hooks you declare),
sadpath computes the set of states the component actually handles and
compares it to the set it requires. The difference is the finding, and the
ratio across the repo is your state coverage.
Detection is type-aware static analysis — no backend, no runtime, no telemetry.
Optional. Drop a sadpath.config.ts at your project root — the CLI auto-detects
it (or pass --config <path>), and the ESLint rule takes the same shape as options.
import { defineConfig } from 'sadpath-core';
export default defineConfig({
require: ['loading', 'error', 'empty'],
customHooks: {
useUser: { data: 'user', loading: 'isLoading', error: 'error' },
},
});sadpath can scaffold the missing branches for you — a deterministic AST codemod, no AI:
sadpath src --fix # rewrites files in place(Or via ESLint: eslint --fix, and the editor quick-fix.)
// after `sadpath --fix`
const query = useQuery(/* … */);
if (query.isPending) return null; // TODO(sadpath): loading state
if (query.isError) return null; // TODO(sadpath): error state
if (query.data.length === 0) return null; // TODO(sadpath): empty state
return <ul>{query.data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;It scaffolds the structure (return null + TODO) — you fill in the UI. Safe
by design: only queries in a block-bodied function ending in return,
idempotent, and mutations / ambiguous shapes are left untouched.
Write a self-contained SVG coverage badge (no shields.io, no network) and regenerate it in CI:
sadpath src --badge # writes ./sadpath-badge.svg
sadpath src --badge docs/cov.svg # custom pathExport findings as SARIF and upload them — they appear as annotations right on the PR diff and in the repo's Security tab:
sadpath src --sarif # writes ./sadpath.sarif
sadpath src --sarif out.sarif # custom pathCopy examples/github-workflow.yml to
.github/workflows/sadpath.yml — it runs sadpath and uploads the SARIF via
github/codeql-action/upload-sarif. (Needs permissions: security-events: write.)
Working today: React + react-query & SWR, queries and mutations
(useMutation / useSWRMutation → pending + error), both const { data } = useQuery() and const q = useQuery() (member-access) patterns, !data loading
guards, the exhaustive-async-states rule (with autofix), collection-aware
empty detection, sadpath.config.ts loading, CLI coverage report, --fix
codemod, --badge, and --sarif for code scanning.
Roadmap: SWR/Apollo adapters · --badge + SARIF · sadpath fix codemod ·
mutations · unauthorized/offline · Vue & Svelte.
- Empty is required only when the data is used as a collection, and counted
as handled via a
.lengthguard or a rendered<Empty…/>; full type-awareness via the type-checker is on the roadmap. - Cross-file error boundaries aren't resolved yet — handling is checked locally.
- Escape hatch coming:
// sadpath-ignore-next.
MIT © Geekles007