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.
305 lines
10 KiB
Markdown
305 lines
10 KiB
Markdown
# Deadnix Architecture
|
|
|
|
## Purpose
|
|
|
|
This note explains how `deadnix` finds unused Nix code.
|
|
It focuses on architecture, not whether each check belongs in our linter.
|
|
|
|
Use this note when deciding whether our linter needs scope analysis.
|
|
|
|
## What Deadnix Is
|
|
|
|
`deadnix` is a dead-code scanner for Nix.
|
|
It finds bindings that introduce names which no live code uses.
|
|
|
|
It is not a general style linter.
|
|
It does not have many independent rules.
|
|
It implements one analysis family: unused declarations.
|
|
|
|
Like `statix`, it parses source text with `rnix`.
|
|
It does not evaluate Nix.
|
|
It does not import files.
|
|
It reasons over local syntax and local scope only.
|
|
|
|
## Minimal Dead-Code Vocabulary
|
|
|
|
- A binding introduces a name.
|
|
- A scope defines where introduced names are visible.
|
|
- A use is an identifier occurrence that refers to a binding.
|
|
- Shadowing happens when an inner scope reuses an outer name.
|
|
- Dead code is a binding that no live code uses.
|
|
- A fixed point is the final result after repeated analysis no longer changes.
|
|
|
|
Dead-code analysis needs more state than a simple syntax lint.
|
|
It must know which names a scope introduces and where those names can be used.
|
|
|
|
## Component Map
|
|
|
|
`deadnix` has fewer layers than `statix`.
|
|
The command-line entry point, file walk, parsing, analysis, reporting, and editing live in one small tool.
|
|
|
|
The important concepts are separate modules:
|
|
|
|
- settings control analysis options;
|
|
- scopes describe where names are introduced;
|
|
- bindings describe individual declared names;
|
|
- usage search answers whether a name appears in a live body;
|
|
- dead-code results connect a binding to its scope;
|
|
- reports render findings;
|
|
- edits remove or rename unused declarations.
|
|
|
|
## Main Pipeline
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
A[Parse CLI options] --> B[Collect input files]
|
|
B --> C[Read each file]
|
|
C --> D[Parse with rnix]
|
|
D --> E[Skip file on parse errors]
|
|
D --> F[Run dead-code analysis]
|
|
F --> G[Print reports unless quiet]
|
|
G --> H[Apply edits when --edit is set]
|
|
H --> I[Exit 1 when --fail and findings exist]
|
|
```
|
|
|
|
The CLI accepts files or directories.
|
|
Directory mode walks `.nix` files.
|
|
Hidden files require an explicit option.
|
|
Excludes filter paths before analysis.
|
|
|
|
The tool skips dead-code diagnostics when a file has parse errors.
|
|
It reports parse errors to the user and continues with other files.
|
|
|
|
## Analysis Settings
|
|
|
|
The analysis has four important switches.
|
|
|
|
`no_lambda_arg` disables checks for single lambda arguments.
|
|
This can avoid noise when an API requires an argument name.
|
|
|
|
`no_lambda_pattern_names` disables checks for names in attribute-set function patterns.
|
|
This matters for `callPackage`-style code, where argument names can be external API.
|
|
|
|
`no_underscore` ignores bindings that start with `_`.
|
|
This treats `_name` as an intentional unused placeholder.
|
|
|
|
`warn_used_underscore` reports underscore-prefixed bindings that code actually uses.
|
|
This enforces the convention that `_name` means unused.
|
|
|
|
These settings are architecture-relevant because dead-code policy depends on API context.
|
|
A repository linter needs a way to express that context.
|
|
|
|
## Scope Model
|
|
|
|
`deadnix` recognizes four scope types.
|
|
|
|
### Lambda pattern scope
|
|
|
|
A lambda pattern is a function argument written as an attribute set pattern.
|
|
It can introduce field names and an optional alias for the whole argument set.
|
|
|
|
Example shape:
|
|
|
|
```nix
|
|
args @ { pkgs, lib, ... }: body
|
|
```
|
|
|
|
This scope can introduce `args`, `pkgs`, and `lib`.
|
|
|
|
### Lambda argument scope
|
|
|
|
A single lambda argument introduces one name.
|
|
|
|
Example shape:
|
|
|
|
```nix
|
|
pkgs: body
|
|
```
|
|
|
|
This scope introduces `pkgs`.
|
|
|
|
### Let scope
|
|
|
|
A `let` expression introduces bindings and inherited names.
|
|
Those names are available in binding expressions and in the body.
|
|
|
|
Example shape:
|
|
|
|
```nix
|
|
let
|
|
inherit (pkgs) hello;
|
|
value = hello;
|
|
in
|
|
value
|
|
```
|
|
|
|
This scope introduces `hello` and `value`.
|
|
|
|
### Recursive attribute-set scope
|
|
|
|
A `rec` attribute set introduces names that sibling attributes can reference.
|
|
`deadnix` models this scope, but treats its bindings as non-removable.
|
|
|
|
That conservative choice avoids deleting public-looking attributes from recursive sets.
|
|
|
|
## Binding Model
|
|
|
|
A binding stores three facts.
|
|
|
|
It stores the identifier name.
|
|
It stores the syntax node that declared the name.
|
|
It stores whether the binding is mortal.
|
|
|
|
A mortal binding can become a dead-code finding.
|
|
A non-mortal binding participates in name lookup but will not be removed.
|
|
|
|
Single lambda arguments that already start with `_` are non-mortal.
|
|
Recursive attribute-set bindings are also non-mortal.
|
|
|
|
A binding also checks for the `deadnix: skip` pragma.
|
|
The pragma applies to a nearby following declaration.
|
|
The scanner searches backward from the declaration and stops after a blank-line boundary.
|
|
|
|
## Usage Search
|
|
|
|
Usage search asks whether a given name appears inside a syntax subtree.
|
|
|
|
The search is scope-aware.
|
|
When it enters a child scope that binds the same name, it stops following that branch.
|
|
That prevents an inner binding from counting as a use of an outer binding.
|
|
|
|
Attribute paths get special handling.
|
|
Ordinary attribute keys do not count as uses.
|
|
Dynamic or string attribute names can contain expressions, so the search looks inside those parts.
|
|
|
|
`inherit` also gets special handling.
|
|
A scope can inherit names from an outer scope.
|
|
The analysis checks whether the inherited expression uses the searched name.
|
|
|
|
## Fixed-Point Analysis
|
|
|
|
`deadnix` repeats the scan until the result set stops changing.
|
|
|
|
This matters because dead code can depend on other dead code.
|
|
A binding can appear used during the first pass because another binding references it.
|
|
If the referring binding is dead, the referenced binding is also dead.
|
|
|
|
The loop tracks dead declaration nodes.
|
|
During later scans, uses inside already-dead bodies do not keep other bindings alive.
|
|
|
|
This fixed-point loop is the central architectural difference from simple local syntax lints.
|
|
It makes dead-code results transitive.
|
|
|
|
## Reporting Model
|
|
|
|
A result stores the scope and binding for one finding.
|
|
The display text combines both parts.
|
|
Examples include unused lambda pattern, unused lambda argument, and unused let binding.
|
|
|
|
Human output uses `ariadne`.
|
|
The reporter groups ranges by line and labels the exact binding names.
|
|
It uses different colors for different scope kinds.
|
|
|
|
JSON output exists behind a feature flag.
|
|
It emits file, message, line, column, and end column.
|
|
|
|
`NO_COLOR` disables colored human output.
|
|
|
|
## Edit Model
|
|
|
|
`deadnix --edit` converts findings into text edits.
|
|
Each edit has a start offset, end offset, and replacement string.
|
|
|
|
The edit behavior depends on scope kind.
|
|
|
|
For unused lambda pattern names, it removes the name and nearby comma or whitespace.
|
|
For unused pattern aliases, it removes the alias syntax.
|
|
For unused lambda arguments, it renames the argument with an underscore prefix.
|
|
For unused `let` assignments, it deletes the assignment.
|
|
For unused inherited names, it deletes only the inherited name.
|
|
|
|
After those edits, it parses the edited source again.
|
|
Then it removes empty `let in` constructs and empty `inherit` statements.
|
|
|
|
This cleanup pass prevents the first edit batch from leaving invalid or useless scaffolding.
|
|
|
|
## Error Handling
|
|
|
|
If a path cannot be stated or read, the CLI reports the file error and continues.
|
|
If a file has parse errors, the CLI reports those errors and skips dead-code analysis for that file.
|
|
|
|
The `--fail` flag changes exit status when findings exist.
|
|
Without `--fail`, findings do not make the command fail.
|
|
|
|
This design separates report generation from CI policy.
|
|
|
|
## Architectural Strengths
|
|
|
|
The scope model is explicit.
|
|
Each scope type declares which bindings it introduces and where those bindings are visible.
|
|
|
|
The fixed-point loop catches transitive dead code.
|
|
A binding used only by dead code also becomes dead.
|
|
|
|
The usage search handles shadowing.
|
|
That prevents common false positives in nested functions and nested `let` expressions.
|
|
|
|
The edit model is tied to analysis results.
|
|
It knows whether a binding came from a lambda, pattern, `let`, or `inherit`.
|
|
|
|
The tool has few moving parts.
|
|
That makes the analysis easy to follow compared to a general multi-rule linter.
|
|
|
|
## Architectural Limits
|
|
|
|
`deadnix` does not evaluate Nix.
|
|
It cannot know which names external callers depend on.
|
|
This matters for module arguments, package function arguments, and public attribute sets.
|
|
|
|
It has global switches rather than project-specific rule contexts.
|
|
For example, all lambda pattern names can be checked or skipped.
|
|
The tool does not know that one directory contains `callPackage` functions.
|
|
|
|
The analysis is local to one file.
|
|
It cannot see that another file imports a function and relies on its argument names.
|
|
|
|
The fixer performs text edits with byte ranges.
|
|
It must handle punctuation and whitespace carefully.
|
|
Pattern edits are harder than simple assignment deletion.
|
|
|
|
The tool has one analysis family.
|
|
It is not a framework for many unrelated rules.
|
|
|
|
## Lessons for Our Linter
|
|
|
|
Dead-code checks need a scope pass.
|
|
A per-node rule interface is not enough when a diagnostic depends on name visibility.
|
|
|
|
Represent scopes explicitly.
|
|
Each scope type should provide introduced bindings and visible bodies.
|
|
|
|
Track shadowing before reporting unused names.
|
|
A text search for an identifier is not sufficient.
|
|
|
|
Use fixed-point analysis for transitive dead code.
|
|
One pass misses names used only by dead declarations.
|
|
|
|
Separate policy from mechanics.
|
|
The mechanics can find unused names.
|
|
Repository policy decides whether module arguments, package arguments, or underscore names count.
|
|
|
|
Treat autofix as scope-specific.
|
|
Removing a pattern name is not the same operation as deleting a `let` assignment.
|
|
|
|
Run cleanup after edits.
|
|
Dead-code fixes can create empty `let` expressions and empty `inherit` statements.
|
|
|
|
## Open Design Questions for Our Linter
|
|
|
|
- Should dead-code analysis be part of `nix-style-linter`, or a separate command?
|
|
- Should policy vary by path, file kind, or suppression comment?
|
|
- Should recursive attribute-set members ever become removable findings?
|
|
- Should underscore names mean ignored, or should used underscore names be errors?
|
|
- Should the linter report unused names without offering autofix first?
|
|
- Should cleanup rules run as ordinary rules or as a post-fix pass?
|