Files
dev/docs/00 INBOX/ai/statix-architecture.md
alexion ddc7952399 feat(xatu): scaffold project
Add the Xatu Rust package as a minimal hello-world skeleton with Cargo workspace metadata and Bazel library, binary, and test targets.

Include the Statix and Deadnix evaluation notes so the new linter work starts with the prior rule and architecture research.
2026-09-17 00:26:12 -04:00

8.8 KiB

Statix Architecture

Purpose

This note explains how statix works as a Nix linter and fixer. It focuses on architecture, not the value of each rule.

Use this note when deciding how complete our repository-local Nix linter should become.

What Statix Is

statix is a syntax-tree linter for Nix. It parses source text into a Nix AST, walks that tree, and runs rules on matching nodes.

It does not evaluate Nix. It does not import files. It does not know the final value of an attribute set. It sees source structure, not runtime semantics.

That design makes statix fast and deterministic. It also limits it to rules that the parser can prove from local syntax.

Minimal Linter Vocabulary

  • Source text is the bytes in a Nix file.
  • A parser converts source text into a syntax tree.
  • A syntax tree records nodes such as lambdas, lists, strings, and operators.
  • A token is a leaf item such as an identifier, comment, or URI literal.
  • A rule inspects one syntax element and optionally returns a diagnostic.
  • A diagnostic reports a span and a message.
  • A suggestion adds a replacement for that span.
  • A fixer applies suggestions back to source text.

Component Map

The upstream architecture document names four major components.

CLI component

The CLI owns operator interaction. It parses subcommands and options, loads configuration, discovers files, and prints output.

It supports these user flows:

  • check: report diagnostics.
  • fix: apply all safe suggestions.
  • single: apply one suggestion at a position.
  • explain: print the long explanation for a rule code.
  • dump: print a sample configuration file.
  • list: print all available rules.

Library component

The library owns lint rules and the data structures that rules return. It defines Report, Diagnostic, Suggestion, Rule, Metadata, Explain, and Lint.

Each rule is a small unit. It receives one syntax element and returns zero or one report.

The library also contains helpers that build replacement syntax. Those helpers parse small replacement fragments back into AST nodes. This makes fixes use valid Nix syntax instead of raw string assembly.

Virtual filesystem component

The virtual filesystem stores file paths and file contents in memory. It assigns each file a small FileId.

The CLI reads files into this store before linting. Diagnostics then carry FileId references instead of path strings.

The VFS supports sequential iteration and parallel iteration. statix check uses parallel iteration across files.

Macro component

The macro component reduces rule boilerplate. A lint macro generates rule metadata, explanation support, and the static rule object.

A rule author writes the rule structure, metadata, documentation, and validation logic. The macro wires that rule into the common linter interface.

Check Pipeline

flowchart TD
    A[Parse CLI options] --> B[Discover configuration]
    B --> C[Build ignore set]
    C --> D[Walk Nix files]
    D --> E[Read files into VFS]
    E --> F[Build active lint map]
    F --> G[Parse each file with rnix]
    G --> H[Walk syntax tree]
    H --> I[Run rules matching each syntax kind]
    I --> J[Collect reports]
    J --> K[Render diagnostics]
    K --> L[Exit 1 when reports exist]

The key optimization is the active lint map. statix groups rules by SyntaxKind before it walks a file. During traversal, it looks up only rules that declared interest in the current node kind.

This avoids running every rule on every node. It also keeps each rule narrow.

Rule Registration

Rules are built into the binary. There is no dynamic rule loading.

A registry macro imports each rule module and builds a static LINTS vector. The CLI filters that vector with configuration. Disabled rule names disappear from the active lint map.

This model has a simple trust boundary:

  • all rule code ships with the tool;
  • configuration can disable rules;
  • configuration cannot define new rules.

Rule Execution Model

Each rule declares the syntax kinds it wants to inspect. For example, one rule can ask for binary-operation nodes. Another rule can ask for URI tokens.

The traversal visits tree entries with preorder_with_tokens. That includes both nodes and tokens. Rules can therefore inspect structured AST nodes and raw tokens.

A rule returns a Report when it finds a problem. The report contains one or more diagnostics. Each diagnostic has a source range and a message. A diagnostic can also include a Suggestion.

Rules do not share state with each other. They also do not run a whole-program analysis. A rule sees the syntax element passed to it and whatever children or parents it chooses to inspect.

Parse Errors

statix turns parser errors into reports. Those reports use code 0 and severity Error.

Parse errors flow through the same output machinery as lint findings. This lets the CLI render syntax errors and lint diagnostics with one reporter.

Output Model

statix has several output formats.

The human format uses ariadne to render annotated source snippets. The errfmt format prints one compact line per diagnostic. The JSON format exists behind a feature flag.

The output layer receives reports and the VFS. It converts byte ranges into line and column positions at render time.

This separates rule logic from display logic. Rules do not need to know how the terminal or editor consumes diagnostics.

Fix Pipeline

A fixable diagnostic contains a Suggestion. A suggestion contains the range to replace and replacement syntax.

statix fix uses the same parser and rule walk as check, but filters for reports that contain suggestions. It then sorts and filters suggestions to avoid overlapping edits.

The fixer applies selected reports to a copy of the source. Then it parses and scans the changed source again. It repeats this process until no more suggestions appear.

That iterative design matters because one edit can expose a later edit. It also avoids applying stale ranges after source text changes.

Single Fix Pipeline

The single command applies one fix at a requested line and column. It converts the line and column to a byte offset. Then it finds the first fixable report whose diagnostic range contains that offset.

This shape supports editor workflows. An editor can request a fix for the diagnostic under the cursor.

Configuration Model

Configuration is small. A statix.toml file can disable rules by name. It can also add ignore globs.

The CLI discovers configuration by walking ancestors from the configured path. If no config exists, it uses defaults.

The command also respects .gitignore unless unrestricted mode is enabled. Ignore handling happens before parsing.

Architectural Strengths

statix has a clean separation between these concerns:

  • file discovery;
  • parsing;
  • rule selection;
  • rule execution;
  • reporting;
  • fixing.

Rules stay small because the framework handles traversal and output. Fixes are safer because replacement syntax comes from parsed Nix fragments. The lint map avoids unnecessary rule calls. Parallel file traversal improves throughput for large trees.

The static rule registry is easy to audit. It fits a repository-local linter well when we want deterministic behavior.

Architectural Limits

statix does not evaluate Nix. It cannot know imported module results, overlays, option merges, or generated attributes.

Most rules must remain local syntax rules. A rule that needs semantic knowledge can easily become wrong.

Fixes rely on byte ranges in source text. They must avoid overlapping ranges. They must rerun after edits because previous ranges become stale.

The macro system reduces rule boilerplate, but it hides generated behavior. That tradeoff is useful for many rules, but it can make debugging harder.

Lessons for Our Linter

Start with a clear rule interface. A rule should declare its name, matching syntax kinds, and diagnostic output.

Keep parsing and diagnostics separate from output. The same diagnostic should support terminal output, editor output, and tests.

Use syntax-kind indexing when rules are local. It prevents unnecessary calls and makes rule ownership explicit.

Treat autofix as a second contract. A diagnostic can exist without a safe fix. A fix must define exact replacement ranges and avoid overlap.

Rerun analysis after applying a batch of fixes. Source ranges become invalid after edits.

Do not add Nix evaluation accidentally. If a rule needs evaluated values, design that as a separate analysis layer.

Open Design Questions for Our Linter

  • Should our linter support fixes, or stay report-only for now?
  • Should rules register by syntax kind like statix?
  • Should our suppression model live in the parser layer or rule layer?
  • Should parse errors use the same diagnostic type as style rules?
  • Should editor-friendly output be part of the first complete architecture?
  • Should future semantic rules use a separate pass from local syntax rules?