@liam-public/node-frontend-lint
v0.4.0
Published
Audit a layered React frontend (pages → components → components/ui): stateless components, one-way import boundaries, and flat (one-file-per-page) pages.
Readme
@liam-public/node-frontend-lint
Audits a layered React frontend against a strict one-way dependency chain:
pages/ Stateful — owns data fetching and local state
↓ passes props to
components/ Stateless — receives data, emits events via callbacks
↓ uses primitives from
components/ui/ Unstyled primitivesParses with the TypeScript compiler API (not regex). Two modes.
App frontend layering (pages/ → components/ → components/ui/):
- Stateless — inside
components/(excludingcomponents/ui/), flagsuseState/useEffect/useReducer/useLayoutEffectand data-fetching hooks (useXxxQuery/useXxxMutation). Move state and fetching up to the page. - Import boundaries —
components/must not import fromapi/,pages/, orhooks/;pages/must not import directly fromcomponents/ui/(that skips a layer). - Flat pages (opt-in via
requireFlatPages) — each page must be a single flat file directly underpages/, not nested in apages/<name>/folder. Page-private sub-components belong incomponents/. - Feature folders (opt-in via
requireFeatureFolders) — every component lives incomponents/<feature>/, not directly undercomponents/. Genuinely cross-feature shared components are allowlisted viasharedComponents.
Data-package purity (opt-in via dataPackages, for portable per-service SDKs like packages/ui-domains/*):
data-presentation— no JSX/.tsxand no UI-kit imports (lucide-react,browser-react-ui, …); a data SDK must stay UI-agnostic so any frontend can reuse it.data-coupling— noapps/*/@/imports and no cross-@ui-domain/*imports.data-framework— noreact/@tanstack/react-queryinsideinfrastructure/(keep the transport framework-agnostic; React Query lives inapplication/).
ui/ and unclassified files (App.tsx, main.tsx, …) are not checked in app mode.
CLI
# scan a directory; exit non-zero if any violations (CI-friendly)
npx frontend-lint src/
# example output
src/components/CategoryFormDialog.tsx:34:5 [stateless/component] components/ must be stateless — 'useState' is not allowed; move local state to the page
src/pages/sources/SourcesPage.tsx:18:1 [import-boundary/page] pages/ must not import '@/components/ui/button' directly — that skips a layer; go through components/Config (.frontend-lint.json)
All fields optional — they override the defaults shown:
{
"uiDir": "/components/ui/",
"componentsDir": "/components/",
"pagesDir": "/pages/",
"statefulHooks": ["useState", "useReducer", "useEffect", "useLayoutEffect"],
"allowHooks": ["useRef"],
"dataHookRegex": "^use[A-Z].*(Query|Mutation|Subscription)$",
"forbidInComponents": ["@/api", "/api/", "@/pages", "/pages/", "@/hooks", "/hooks/"],
"forbidInPages": ["@/components/ui", "/components/ui/"],
"requireFlatPages": false,
"requireFeatureFolders": false,
"sharedComponents": [],
"dataPackages": { "dir": "/ui-domains/" }
}Pass a path with --config <file>. Suppress a single intentional case (e.g. the documented trivial focus/animation exception) with a frontend-lint-ignore comment on the line or the line above.
Baseline (adopt on a codebase with existing violations)
# 1. record current violations (run once, commit the file)
frontend-lint --update-baseline --baseline .frontend-lint-baseline.json src
# 2. in CI — passes unless a NEW violation appears; baselined ones shrink as you fix them
frontend-lint --baseline .frontend-lint-baseline.json srcKeys are path :: rule :: detail, so they survive line edits.
Programmatic
import { scanFile } from '@liam-public/node-frontend-lint'
const findings = scanFile('components/SourceTable.tsx', code)
// [{ line, column, layer: 'component', rule: 'stateless' | 'import-boundary', message, detail }]typescript is a peer dependency.
