Skip to main content

Documentation Index

Fetch the complete documentation index at: https://grantmaster.dev/llms.txt

Use this file to discover all available pages before exploring further.

Custom ESLint Rules

13 project-specific ESLint rules enforcing architecture boundaries, design system compliance, and data access patterns. Rule files: .eslint/rules/*.js Config: eslint.config.js Debt baselines: config/eslintDebtBaselines.js

Overview

RuleCategorySeverityAuto-fixable
no-direct-firestore-importArchitectureerrorNo
no-eventbus-in-componentsArchitecturewarnNo
no-derived-stateArchitectureoffNo
no-direct-onsnapshotData AccesswarnNo
prefer-typed-firestore-refsData AccesswarnNo
require-firestore-limitData AccesswarnNo
no-legacy-tailwind-paletteStylingerrorNo
no-raw-button-with-design-systemDesign SystemerrorNo
no-raw-text-input-with-design-systemDesign SystemerrorNo
no-status-badge-bypassDesign SystemwarnNo
prefer-empty-state-componentDesign GovernancewarnNo
prefer-skeleton-loader-presetsDesign GovernancewarnNo
prefer-page-headerDesign GovernancewarnNo
Rules set to error will block CI. Rules set to warn are migration aids and will be promoted to error once legacy debt is resolved.

Architecture Rules

no-direct-firestore-import

Why: Enforces the service layer architecture. Components, hooks, contexts, and pages must never import Firebase SDK modules directly — all data access goes through BaseService subclasses and core abstractions. Applies to: Files under /components/, /hooks/, /contexts/, /pages/. Skipped for: /services/, /core/, /utils/, test files. Flags:
// BAD -- direct firebase/firestore import in a component
import { collection, getDocs } from 'firebase/firestore';
// BAD -- direct firebase/functions import in a hook
import { httpsCallable } from 'firebase/functions';
// BAD -- direct firebase/storage import in a page
import { ref, uploadBytes } from 'firebase/storage';
Fix:
// GOOD -- use the service layer
import { projectService } from '@/features/projects/services/projectService';

// GOOD -- use the cloud functions abstraction
import { callFunction } from '@/core/cloudFunctionsService';

// GOOD -- use the storage abstraction
import { uploadFile } from '@/core/storageService';
Error messages:
  • "Direct firebase/firestore imports are not allowed here. Use the service layer (BaseService) instead."
  • "Direct firebase/functions imports are not allowed here. Use callFunction() from @/core/cloudFunctionsService instead."
  • "Direct firebase/storage imports are not allowed here. Use uploadFile()/deleteFile() from @/core/storageService instead."
Note: firebase/auth is intentionally excluded — AuthContext wraps it directly.

no-eventbus-in-components

Why: EventBus is infrastructure glue. Components importing the EventBus singleton create hidden coupling and make rendering unpredictable. Event emission and subscription belong in services and infrastructure init code. Applies to: Files under /components/, /pages/. Skipped for: /services/, /core/, /extensions/, /infrastructure/, /contexts/, test files. Flags:
// BAD -- importing eventBus in a component
import { eventBus } from '@/core/eventBus';

function ProjectCard() {
  eventBus.emit('project:updated', data);
}
Fix:
// GOOD -- emit events from the service layer
// In projectService.ts:
import { eventBus } from '@/core/eventBus';

async function updateProject(data) {
  await save(data);
  eventBus.emit('project:updated', data);
}
Error message: "Direct eventBus imports are not allowed in components. Move event emission/subscription to the service layer. See docs/architecture/EVENT_BUS_RULE.md for guidance."

no-derived-state

Why: Detects the React anti-pattern of initializing useState from props or other state values. This creates a copy that can become stale when the source changes. The value should be calculated during render or memoized with useMemo. Severity: Currently off (disabled). Intended to be enabled as warn with allowedPatterns: ['^initial', '^default'] to permit explicit opt-in names like initialValue or defaultSelected. Applies to: React components (PascalCase functions) and custom hooks (use*). Skipped for: Lazy initializers (arrow/function expressions passed to useState). Flags:
// BAD -- useState from a prop
function UserCard({ name }) {
  const [localName, setLocalName] = useState(name);
}

// BAD -- useState from another state variable
function Form({ items }) {
  const [selected, setSelected] = useState(items[0]);
  const [count, setCount] = useState(selected); // derived from state
}

// BAD -- expression involving props
function Widget({ width, height }) {
  const [area, setArea] = useState(width * height);
}
Fix:
// GOOD -- calculate during render
function UserCard({ name }) {
  const displayName = name.toUpperCase();
}

// GOOD -- use useMemo
function Widget({ width, height }) {
  const area = useMemo(() => width * height, [width, height]);
}

// GOOD -- lazy initializer (allowed, not flagged)
function List({ items }) {
  const [sorted, setSorted] = useState(() => sortItems(items));
}
Error messages:
  • "useState initialized from props \"{{propName}}\" creates derived state. Consider calculating this value during render or using useMemo instead."
  • "useState initialized from another state variable \"{{stateName}}\" creates derived state. Consider calculating this value during render or using useMemo instead."
  • "useState initialized from destructured prop \"{{propName}}\" creates derived state. Consider calculating this value during render or using useMemo instead."
Configuration: Accepts an allowedPatterns option — an array of regex strings for prop/state names that are permitted to initialize useState.

Data Access Rules

no-direct-onsnapshot

Why: All Firestore real-time subscriptions must go through listenerManager to benefit from listener pooling (30-50% cost reduction), built-in memory leak detection, and centralized debug logging. Applies to: All source files except infrastructure. Skipped for: listenerManager.ts, useFirestoreListener.ts, firestoreCollections.ts, test files. Flags:
// BAD -- direct onSnapshot import
import { onSnapshot, collection } from 'firebase/firestore';

onSnapshot(collection(db, 'projects'), (snap) => { ... });
Fix:
// GOOD -- use listenerManager
import { listenerManager } from '@/core/listenerManager';

listenerManager.subscribe('projects', query, (snap) => { ... });
Error message: "Direct onSnapshot import detected. Use listenerManager.subscribe() instead for cost optimization and leak detection. See docs/LISTENER_MANAGER_GUIDE.md for migration examples."

prefer-typed-firestore-refs

Why: The typed helpers in @/core/firestoreCollections (collections.*, docs.*, queries.*) provide compile-time type safety for collection paths and document references. Raw collection()/doc() imports bypass this. Applies to: Files under /services/ only. Skipped for: /src/core/, /src/tests/. Flags:
// BAD -- raw collection/doc imports in a service
import { collection, doc, getDocs } from 'firebase/firestore';

const ref = collection(db, 'projects');
const docRef = doc(db, 'projects', id);
Fix:
// GOOD -- typed helpers
import { collections, docs } from '@/core/firestoreCollections';

const ref = collections.projects();
const docRef = docs.projects(id);
Error message: "Prefer typed Firestore refs from \@/core/firestoreCollections` (collections/docs/queries) instead of importing `collection`/`doc` directly.”`
Note: This is intentionally a warn severity as a migration aid.

require-firestore-limit

Why: Unbounded Firestore collection reads can pull entire collections, causing performance degradation and excessive billing. Every getDocs() call in service code must include a limit() constraint or an exact document lookup via where("__name__", "==", ...). Applies to: Files under /services/. Skipped for: Test files. Flags:
// BAD -- getDocs without limit
const q = query(collection(db, 'expenses'));
const snapshot = await getDocs(q);

// BAD -- collection ref directly
const snapshot = await getDocs(collection(db, 'expenses'));
Fix:
// GOOD -- with limit
const q = query(collection(db, 'expenses'), limit(100));
const snapshot = await getDocs(q);

// GOOD -- exact document lookup (inherently bounded)
const q = query(collection(db, 'expenses'), where('__name__', '==', docId));
const snapshot = await getDocs(q);
Error message: "Firestore collection reads in services must include \limit(…)` or an exact `where(“name”, ”==”, …)` lookup.”`

Styling Rules

no-legacy-tailwind-palette

Why: The codebase has migrated to design-system tokens (primary-*) for primary colors. Files that already use primary-* tokens should not mix in raw blue-* Tailwind classes, which bypasses theming and dark mode support. Applies to: Files that already contain primary- in their source text (indicating they have adopted the design-system palette). Skipped for: Test files, files that have not yet adopted primary-* tokens. Flags:
// BAD -- blue-* in a file that already uses primary-* tokens
<div className="bg-primary-600 text-blue-500">...</div>
<span className={cn('border-blue-200', 'ring-primary-400')}>...</span>
Detected class prefixes: bg-blue-*, text-blue-*, border-blue-*, from-blue-*, ring-blue-*, to-blue-*, via-blue-* (shades 50-950). Fix:
// GOOD -- use primary-* tokens consistently
<div className="bg-primary-600 text-primary-500">...</div>
<span className={cn('border-primary-200', 'ring-primary-400')}>...</span>
Error message: "Use design-system \primary-` tokens instead of raw `blue-` Tailwind classes in tokenized files.”` Debt baseline: Legacy files are exempted via mixedPrimaryBlueDebtFiles in config/eslintDebtBaselines.js. Remove files from that list as they are migrated.

Design System Rules

no-raw-button-with-design-system

Why: Once a file imports the design-system Button component, any raw <button> elements in the same file are inconsistent. All interactive buttons should use the Button component for consistent styling, loading states, and variant support. Applies to: Files that import from @/components/ui/button. Skipped for: Test files, files under /components/ui/ (design system internals). Flags:
// BAD -- raw <button> alongside design-system Button
import { Button } from '@/components/ui/button';

function Toolbar() {
  return (
    <div>
      <Button>Save</Button>
      <button className="px-4 py-2 bg-blue-500">Cancel</button>  {/* flagged */}
    </div>
  );
}
Fix:
// GOOD -- use Button for everything
import { Button } from '@/components/ui/button';

function Toolbar() {
  return (
    <div>
      <Button>Save</Button>
      <Button variant="outline">Cancel</Button>
    </div>
  );
}
Error message: "Use `Button` from `@/components/ui/button` instead of a raw `<button>` in this file." Debt baseline: Legacy files are exempted via rawButtonDebtFiles in config/eslintDebtBaselines.js.

no-raw-text-input-with-design-system

Why: Same principle as no-raw-button-with-design-system, but for text-like inputs. Files that import the design-system Input should not also use raw <input> elements for text-type fields. Applies to: Files that import from @/components/ui/input. Skipped for: Test files, files under /components/ui/. Detected input types: text, email, password, search, tel, url, number, date, datetime-local, month, time, week. Non-text types like checkbox, radio, file, hidden, and range are not flagged. Flags:
// BAD -- raw <input type="text"> alongside design-system Input
import { Input } from '@/components/ui/input';

function SearchBar() {
  return (
    <div>
      <Input placeholder="Main search" />
      <input type="text" placeholder="Filter" />  {/* flagged */}
      <input type="email" />                       {/* flagged */}
      <input type="checkbox" />                    {/* NOT flagged (non-text) */}
    </div>
  );
}
Fix:
// GOOD -- use Input for all text-like inputs
import { Input } from '@/components/ui/input';

function SearchBar() {
  return (
    <div>
      <Input placeholder="Main search" />
      <Input placeholder="Filter" />
      <Input type="email" />
      <input type="checkbox" />  {/* non-text types are fine as raw */}
    </div>
  );
}
Error message: "Use `Input` from `@/components/ui/input` instead of a raw text-like `<input>` in this file." Debt baseline: Legacy files are exempted via rawTextInputDebtFiles in config/eslintDebtBaselines.js.

no-status-badge-bypass

Why: The StatusBadge component and mapDomainStatus() helper centralize status display logic (colors, icons, accessibility). Local helper functions like getStatusBadge(), getStatusColor(), or statusColor() bypass this system and lead to inconsistent status rendering. Applies to: Files that import from @/components/ui/StatusBadge. Skipped for: Test files. Detected helper names: getStatusBadge, getStatusColor, statusColor. Flags:
// BAD -- local status helper alongside StatusBadge import
import { StatusBadge } from '@/components/ui/StatusBadge';

function getStatusColor(status: string) {  // flagged
  return status === 'active' ? 'green' : 'gray';
}
Fix:
// GOOD -- use StatusBadge and mapDomainStatus
import { StatusBadge, mapDomainStatus } from '@/components/ui/StatusBadge';

function ProjectRow({ status }) {
  return <StatusBadge status={mapDomainStatus(status)} />;
}
Error message: "Use \StatusBadge` and `mapDomainStatus()` instead of local status helper implementations in this file.”` Debt baseline: Legacy files are exempted via statusBadgeHelperDebtFiles in config/eslintDebtBaselines.js.

Design Governance Rules

prefer-empty-state-component

Why: Plain “No data/results found” strings create inconsistent empty-state UX and miss guidance/action affordances. Empty states should be rendered through shared primitives. Applies to: Feature UI files (non-test). Flags: Plain empty-state copy in primitive elements like <p>, <div>, <span>, <td>. Fix:
// GOOD -- use shared EmptyState component
<SurfaceEmptyState
  title="No projects found"
  description="Try adjusting your filters or create a new project."
  actionLabel="New project"
  onAction={onCreateProject}
/>
Message: "Use \EmptyState` or `SurfaceEmptyState` instead of plain “No data/results” text.”`

prefer-skeleton-loader-presets

Why: Ad-hoc animate-pulse markup drifts visually and duplicates loading patterns. Shared skeleton presets keep loading states consistent and easier to maintain. Applies to: Feature UI files (non-test), excluding skeleton internals. Flags: animate-pulse class usage in feature code. Fix:
// GOOD -- route/page-specific skeleton preset
<Suspense fallback={<TablePageSkeleton />}>
  <LazyExpensesPage />
</Suspense>
Message: "Use \SkeletonLoader` presets (or surface skeleton components) instead of ad-hoc `animate-pulse` markup.”`

prefer-page-header

Why: Page-level title blocks should be standardized to ensure consistent hierarchy, spacing, and actions layout. Applies to: Page-like files (src/pages/**, feature page components, *Page.tsx), excluding design-system internals and tests. Flags: Manual <h1> usage when a shared page header is not used. Fix:
// GOOD -- standardized page header
<PageHeader
  title="Platform Users"
  description="Manage users, access, and status across tenants."
  actions={<Button variant="outline">Export</Button>}
/>
Message: "Use `PageHeader` (or `SurfacePageHeader`) for page-level titles instead of manual `<h1>` markup."

How to Disable (When Necessary)

Use inline ESLint disable comments sparingly and always include a justification:
// eslint-disable-next-line custom/no-direct-firestore-import -- one-off migration script
import { writeBatch } from 'firebase/firestore';
Acceptable reasons to disable:
  • Migration scripts that run once and are not part of the main app
  • Design system internals that need low-level access by definition
  • Test utilities that mock infrastructure directly
  • Third-party integration adapters that bridge external APIs
Never disable to avoid refactoring. If you find a rule too noisy, add the file to the appropriate debt baseline list in config/eslintDebtBaselines.js and create a ticket to migrate it.

Adding New Rules

Custom rules live in .eslint/rules/ and follow the standard ESLint rule structure:
export default {
  meta: {
    type: 'problem' | 'suggestion',
    docs: {
      description: '...',
      category: 'Best Practices',
      recommended: false,
    },
    messages: {
      ruleId: 'Human-readable error message.',
    },
    schema: [],  // options schema (JSON Schema)
  },

  create(context) {
    const filename = context.getFilename();
    // Return AST visitor object
    return {
      ImportDeclaration(node) { ... },
      JSXOpeningElement(node) { ... },
    };
  },
};
To wire a new rule:
  1. Create the rule file in .eslint/rules/my-rule.js
  2. Import it in eslint.config.js and add to the custom plugin’s rules object
  3. Set the severity in the main rules block: 'custom/my-rule': 'warn'
  4. If there is existing debt, create a baseline list in config/eslintDebtBaselines.js and add a file-level override to disable the rule for those files
  5. Update this document

Maintenance

Update this document when adding, modifying, or removing custom ESLint rules. The debt baseline files in config/eslintDebtBaselines.js should shrink over time as legacy code is migrated — review them periodically and remove entries for files that have been fixed.