Architecture

How @hyperfrontend/linter is put together: one configuration per run, one parse per file, every rule's listeners run in a single traversal, and services computed once and shared.

System Overview

The command, or lint called from code, finds the configuration, registers the families its blocks name, enumerates the files, and lints them one at a time. For each file the engine picks the language by extension, parses once, loads the rules in effect for that file, and runs all of their listeners in one walk of the tree. It then drops what the directives suppress, applies fixes and lints again until no fix applies (at most ten passes), and prints. Nothing is kept between runs.

Design Principles

  1. Behaviour is the specification. A reimplemented rule keeps the upstream rule's messages, positions and fixes, and implements the options it documents, not the upstream rule's whole option surface.

    // ✅ the documented options, nothing else
    const { ignoredDependencies = [], ignoredFiles = [] } = context.options ?? {}
    // ❌ the upstream rule's whole option surface
    const options = { ...UPSTREAM_DEFAULTS, ...context.options }
  2. One parse, one traversal. Rules return listeners keyed by node kind; the engine groups them and walks the tree once, so adding a rule adds listeners, not passes.

    // ✅
    create: (context) => ({
      TSEnumDeclaration(node) {
        context.report({ start: node.start, end: node.end, message })
      },
    })
    // ❌ a rule walking the tree itself
    create: (context) => ({
      Program(node) {
        walk(node, visitEverything)
      },
    })
  3. Services are lazy and shared. Scope analysis and code paths are built on first request and memoised per tree; a rule asks, it never rebuilds.

    // ✅
    const analysis = scopeAnalysisOf(context)
    // ❌
    const analysis = analyseScopes(context.tree)
  4. Offsets in, positions out. Rules report offsets; the engine turns them into lines and columns, applies directives by position and orders the messages, so no rule computes a line number.

    // ✅
    context.report({ start: node.start, end: node.end, message })
    // ❌
    context.report({ line: text.slice(0, node.start).split('\n').length, message })
  5. No external packages, and nothing run that a file does not need. The parsers and the globals data are the linter's own; a rule's create runs only for a file whose configuration turns it on.

Module Composition

ModuleResponsibility
api/lint: configuration, file selection and linting as a function
bin/The hf-lint command
cli/Arguments, the command's flow, exit codes
config/Loading the nearest lint.config.ts, resolving blocks per file, directives
files/Enumerating files from paths and globs, the glob matcher
engine/The rule API, the registry of packaged rules and families, the per-file lint loop, traversal, fixes, report shapes
languages/One adapter per language: parse, children table, comments
code/TypeScript, TSX and JavaScript scanner and parser, ESTree-shaped
markdown/Block and inline Markdown parser, mdast-shaped
text/Line maps and positions
scope/Scopes, variables, references and their resolution
flow/Code-path segments for the rules that reason about reachability
jsdoc/JSDoc blocks, tags and type expressions, and their attachment to nodes
regex/Regular-expression patterns and their validity
output/Stylish and JSON formatters
presets/The rule sets and globals a configuration is built from
helpers/The rule-author helpers published as /helpers
testing/testRule, the spec helper every rule's spec uses, published as /testing
rules/<family>/One module per rule, shared helpers under shared/; catalogue.ts and ids.ts list them

Data Flow

Core Interfaces

/** A rule module's default export. */
interface RuleModule<Options = unknown, Node extends TreeNode = TreeNode> {
  readonly id: string
  readonly language: 'code' | 'json' | 'markdown' | 'text'
  readonly fixable: boolean
  create(context: RuleContext<Options, Node>): Listeners<Node>
}

/** What a rule receives. */
interface RuleContext<Options, Node extends TreeNode> {
  readonly file: RuleFile
  readonly tree: Node
  readonly options: Options
  readonly settings: Readonly<Record<string, unknown>>
  readonly language: LanguageSettings
  readonly report: (problem: Problem) => void
}

/** What a rule reports. */
interface Problem {
  readonly start: number
  readonly end?: number
  readonly at?: Location
  readonly message: string
  readonly fix?: { readonly start: number; readonly end: number; readonly text: string }
}

/** One configuration block. */
interface ConfigBlock {
  readonly files?: readonly string[]
  readonly ignores?: readonly string[]
  readonly rules?: Readonly<Record<string, 'off' | 'warn' | 'error' | readonly ['warn' | 'error', unknown]>>
  readonly settings?: Readonly<Record<string, unknown>>
  readonly language?: Partial<LanguageSettings>
  readonly families?: Readonly<Record<string, RuleFamily>>
}

Links