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
-
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 } -
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) }, }) -
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) -
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 }) -
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
createruns only for a file whose configuration turns it on.
Module Composition
| Module | Responsibility |
|---|---|
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
- README.md: installing and running the linter
- Rule catalogue: every packaged rule, with options and examples