-
Notifications
You must be signed in to change notification settings - Fork 0
Linting Best Practices
🔄 Auto-generated mirror. Canonical source:
contributing/linting-best-practices.md. Edit there — changes here are overwritten by CI.
The authoritative reference for this repo's linting: how it's gated, how to run it, what every check enforces, and how to add a new one. Other docs link here rather than restating it.
This is a single-package repo (display-case). The quality gate is Biome
(format + lint), tsc --noEmit (types), this project's own static checks
via display-case check --structure --tokens --ssr, and a small set of
project-specific custom checks under tools/lint/ for rules
Biome and tsc can't express (run together via bun run lint:checks). The
sprawling multi-package lint runner of the monorepo this came from is gone; what
remains is a lean set scoped to one package.
Quality gates run through git hooks (husky) locally, backstopped by a
GitHub Actions PR workflow (.github/workflows/ci.yml).
-
.husky/pre-commit— on commits that touch code (not docs/specs only), runs the static, browser-free gate:Unfixable Biome errors, a failing custom check, type errors, a non-zerobun run lint:fix # biome (format+lint, auto-fix) + custom checks (tools/lint) bun run typecheck # tsc --noEmit bun run check # display-case --structure --tokens --ssr (this project's own checks) bun test # unit tests
display-case check, or failing tests abort the commit. -
.husky/pre-push— runs the browser-backed end-to-end suite:The a11y and visual phases ofbun run e2e
display-case checkare browser-backed too; they are part of the broader review/e2e gate rather than the per-commit hook, so a commit never blocks on Chromium being installed (see ../docs/testing.md — The default backend is lazy and optional).
The husky model is the fast local gate; the PR workflow is the backstop.
Hooks are bypassable (--no-verify), absent in a clone where bun install never
ran, and skipped entirely for bot/GitHub-web edits and off-machine merges (the
GitHub merge button) — any of which can land unlinted code. CI re-runs the same
commands on every open pull request so nothing merges unchecked:
-
lint—bun run lint(the--no-fixverification form, notlint:fix) +bun run typecheck -
check—bun run check(structure + tokens + ssr) -
test—bun test -
e2e—bun run e2e(installs Chromium viaplaywright install --with-deps chromium) -
openspec—bun tools/openspec-merge-guard.ts(blocks an unarchived OpenSpec proposal from merging to main — see the checks table below). PR-only; it needs the PR's base commit, so it has no local pre-commit equivalent.
The jobs run in parallel so each surfaces as its own PR check; only e2e
provisions Chromium.
bun run lint # biome (check) + custom checks
bun run lint:fix # biome (--write) + custom checks (--fix); matches pre-commit
bun run lint:checks # just the custom checks (tools/lint)
bun run typecheck # tsc --noEmit (one tsconfig)
bun run check # display-case --structure --tokens --ssr (no browser)
display-case check . # everything, including a11y + visual (needs Chromium)-
biome check --writefixes by default — it matchespre-commit, which auto-fixes and re-stages. Drop--writeto verify without touching files. -
--structure --tokens --ssris the browser-free subset. Naming those three phases runs only them; with no phase flag,display-case checkalso runs the a11y and visual phases, which need the Playwright/axe toolchain. See ../docs/testing.md.
| Check | Enforces | Scope | Escape |
|---|---|---|---|
biome |
Formatting + recommended lint rules, plus the tuned rules below | all files (per biome.json) |
// biome-ignore <rule>: <reason> |
display-case --structure |
Static best-practice rules over the showcase: coverage (*.case.tsx + *.placard.md), catalog integrity (levels, slugs, flows, tweak defaults), case-content (interactive-cases-keyed), and opt-in composition rules. Severity-graded: error fails, warn is non-fatal unless --strict. |
roots from display-case.config.ts
|
// display-case: allow-<rule-id> <reason> (also no-case / no-placard / allow-orphan / unclassified), per-path ignore globs, or per-rule false/'warn'. Full rule list & escapes: ../docs/testing.md — Structure checks
|
display-case --tokens |
Every var(--token) in the package resolves to a custom property the package defines (in globalStyles or an inline style object) — catches foreign/typo'd token names that silently fall back. Static parse, no browser. |
package source (component CSS/TSX and case files) |
allow: unknown-token on the line or the line above, or the config's tokens.allow list. Detail: ../docs/testing.md — Token conformance
|
display-case --ssr |
Every case renders on the server (renderToString, no browser) — keeps a case's render pure, so browser-only APIs stay in effects/handlers. A browserOnly case is counted, not flagged. |
all discovered cases | declare the component browserOnly in its case meta. Detail: ../docs/testing.md
|
types |
tsc --noEmit — one tsconfig for the whole package |
all .ts/.tsx
|
— |
biome plugin: e2e locators |
e2e specs must not call getByText()/getByRole() — drive the chrome via getByTestId(DcTestIds.*) (see testing-best-practices.md §6). GritQL plugin tools/lint/e2e-locators.grit, scoped to e2e/** by a biome.json override; runs in biome check. |
e2e/** |
// biome-ignore lint/plugin: <reason> on the line |
biome plugin: no inline svg |
No inline <svg> in the browse chrome — the Vitrine design system is "Unicode glyphs only". AST name-match, so <svgPath>/<Svg> are not flagged. GritQL plugin tools/lint/no-custom-svg.grit, scoped to src/ui/**; runs in biome check. |
src/ui/** |
// biome-ignore lint/plugin: <reason> (in JSX, immediately before the element) |
spec-purity |
No implementation/tool names in a behavior spec; bullet GIVEN/WHEN/THEN, not bolded (--fix converts bolded keywords) |
openspec/specs/**/spec.md |
<!-- allow: <reason> --> on the line |
openspec-merge-guard |
A proposal may stay open while its PR is reviewed, but must not merge to main unarchived. A PR's openspec/ diff may only add/modify archived proposals (openspec/changes/archive/**) and spec changes (openspec/specs/**); adding or modifying an active (unarchived) proposal under openspec/changes/<name>/** fails the run. Deletions there are fine — that is what archiving looks like. A diff-vs-base check (tools/openspec-merge-guard.ts), so it runs only in CI (the openspec job), not in bun run lint/pre-commit. |
openspec/changes/** in the PR diff |
archive the change (bun run openspec archive <name> → moves it to archive/ and folds its spec deltas into openspec/specs/) |
spec-purity is the one remaining script check inside bun run lint:checks
(it scans Markdown, which a GritQL JS plugin can't) — it lives in
tools/lint/. The two AST-pattern rules (e2e locators, no
inline <svg>) are Biome GritQL plugins that run inside biome check.
openspec-merge-guard is a separate CI-only script (it needs a diff base, so
it isn't in lint:checks); run it locally with bun run check:openspec-merge
(diffs against origin/main by default).
OpenSpec structural validity (required sections, ≥1 scenario per requirement) is the OpenSpec CLI's job (
openspec validate), not duplicated here. The CLI isn't a repo dependency, so it isn't wired into the default gate; run it ad hoc if you have it installed.spec-purityowns only the project-specific conventions the CLI doesn't know (forbidden terms, bullet scenario format).
The shipped biome.json keeps Biome's recommended set plus these tuned rules. The
effective rule set a contributor sees:
-
noExplicitAny— off (pragmatic;anyis allowed). -
noUnusedImports,noUnusedVariables— error. -
noReExportAll— error, except inindex.ts/index.tsxbarrels. -
noDefaultExport— error, except in*.case.tsx,display-case.config.ts,*.d.ts, andplaywright.config.ts(these legitimately default-export). -
noNestedTernary,noNonNullAssertion,noParameterAssign,useConsistentBuiltinInstantiation— error. -
useImportType— error (type-only imports must useimport type). -
useNamingConvention— error (object-literal property names exempted). -
noRestrictedImports— error for the optional visual toolchain (playwright,@axe-core/playwright,pixelmatch,pngjs): these must be loaded lazily viaawait import()so browsing/manifest/render stay dependency-light.src/checks/providers/**is exempted (the lazy leaves that may statically import them). This is the Biome-expressed form of the monorepo'simport-boundariescheck — see coding-best-practices.md §6. - Two GritQL plugins run via
overrides[].plugins(custom messages, suppress with// biome-ignore lint/plugin: <reason>): e2e-locators.grit (e2e/**) forbidsgetByText()/getByRole(); no-custom-svg.grit (src/ui/**) forbids inline<svg>. - Imports are auto-organized (assist
organizeImports).
Formatting: 2-space indent, 80-col width, single quotes (double in JSX), trailing commas, no semicolons, always-parenthesized arrows.
The host monorepo's
apps/*overrides (e.g.noConsolein app source, route-file exemptions) are dropped in the standalone config — they referenced packages that don't exist here.
Pick the cheapest home that fits — in this order:
-
A Biome rule or GritQL plugin. Prefer Biome over anything custom whenever
the rule fits. A built-in rule is best (e.g.
noRestrictedImportsfor the lazy toolchain) — one config line, no maintenance. For an AST pattern with no built-in rule (e.g. thegetByText/getByRoleban, or the inline-<svg>ban), write a small GritQL plugin (*.grit) and reference it frombiome.jsonplugins(scope it with anoverrides[].pluginsentry); it runs insidebiome checkwith a custom message and// biome-ignore lint/plugin:suppression. GritQL fits call/member/JSX-element patterns cleanly; reach for a script (below) when the rule must match text inside string literals or span non-JS files (e.g.spec-purityover Markdown). -
A Display Case structure rule. Rules about the showcase (coverage, catalog
integrity, case content, composition) extend the structure phase in
src/checks/structure-check.ts. Add the rule there, give it a
stable
<rule-id>(so// display-case: allow-<rule-id>works), assign a severity (error/warn), and add a unit test next to it (structure-check.test.ts). -
A custom check in
tools/lint/. For a cross-cutting rule over files the above don't cover (thee2e/specs, the OpenSpec specs, chrome source) and that Biome can't express. Writetools/lint/<name>.ts— scan withBun.Glob, printpath:line: messageto stderr,process.exit(1)on any violation, and honor a// allow: <reason>(or<!-- allow: -->) escape — then add'<name>'to theCHECKSarray in tools/lint/index.ts.
A new rule that is fundamentally about TypeScript types belongs in the type system
(tsc --noEmit), not a separate scanner.
- coding-best-practices.md — the coding rules several of these checks enforce (import-type discipline, default-export policy, SSR-pure render).
-
testing-best-practices.md — test conventions and the
e2e locator discipline (the
getByText/getByRolehalf is enforced by the Biome GritQL plugintools/lint/e2e-locators.grit). -
../docs/testing.md — the product reference for
display-case check: the full structure rule list, token conformance, SSR, and the a11y/visual phases, with every per-rule escape.
Auto-generated mirror of the Display Case docs. Edit the repo, not the wiki.
Product documentation
- AI Agents
- CLI
- Configuration
- Documentation Panel
- Examples
- Hierarchy
- Quick Start
- Style Engines
- Testing
- Theming
- Tweaks
- Writing Cases
- Writing Placard Docs
Contributing (engineering)