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.
This commit was merged in pull request #63.
This commit is contained in:
304
docs/00 INBOX/ai/deadnix-architecture.md
Normal file
304
docs/00 INBOX/ai/deadnix-architecture.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# 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?
|
||||
Reference in New Issue
Block a user