Drop the statix and deadnix packages from the fix app now that the repository has a local Nix style linter path. Remove the obsolete statix configuration, document the evaluated rule sets and architectures, and keep vendored third-party files out of treefmt.
262 lines
8.8 KiB
Markdown
262 lines
8.8 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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?
|