Compare commits

...

3 Commits

Author SHA1 Message Date
324d40c85d style(nixos): suppress cfg order findings 2026-09-26 23:32:34 -04:00
dd82367dcb feat(natu): support inline suppressions 2026-09-26 23:30:01 -04:00
ac5cc8d659 fix(natu): preserve NixOS cfg bindings 2026-09-26 23:24:54 -04:00
59 changed files with 131 additions and 79 deletions

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.actualbudget;
cfg = config.guests.actualbudget; # NOLINT(let_order)
dataDir = "/data";
in
{

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.copyparty;
cfg = config.guests.copyparty; # NOLINT(let_order)
volumeType = lib.types.submodule {
options = {

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.homepage;
cfg = config.guests.homepage; # NOLINT(let_order)
environmentFile = "/run/secrets/homepage.env";
in
{

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.palworld;
cfg = config.guests.palworld; # NOLINT(let_order)
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.valheim;
cfg = config.guests.valheim; # NOLINT(let_order)
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";

View File

@@ -6,7 +6,7 @@
}:
# Alacritty as the desktop terminal.
let
cfg = config.modules.desktop.apps.alacritty;
cfg = config.modules.desktop.apps.alacritty; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -7,7 +7,7 @@
}:
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
let
cfg = config.modules.desktop.apps.firefox;
cfg = config.modules.desktop.apps.firefox; # NOLINT(let_order)
user = config.user.name;
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
in

View File

@@ -6,7 +6,7 @@
}:
# Ghostty as the desktop terminal.
let
cfg = config.modules.desktop.apps.ghostty;
cfg = config.modules.desktop.apps.ghostty; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Obsidian as the desktop note-taking vault.
let
cfg = config.modules.desktop.apps.obsidian;
cfg = config.modules.desktop.apps.obsidian; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# r2modman, a Thunderstore mod manager.
let
cfg = config.modules.desktop.apps.r2modman;
cfg = config.modules.desktop.apps.r2modman; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Signal desktop messenger.
let
cfg = config.modules.desktop.apps.signal;
cfg = config.modules.desktop.apps.signal; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Steam game launcher and runtime integration.
let
cfg = config.modules.desktop.apps.steam;
cfg = config.modules.desktop.apps.steam; # NOLINT(let_order)
in
{
options.modules.desktop.apps.steam.enable = lib.mkEnableOption "Steam game launcher";

View File

@@ -6,7 +6,7 @@
}:
# Vesktop, a Discord client with Vencord built in.
let
cfg = config.modules.desktop.apps.vencord;
cfg = config.modules.desktop.apps.vencord; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -1,7 +1,7 @@
{ config, lib, ... }:
# The desktop aggregator: one flag brings up the whole graphical session.
let
cfg = config.modules.desktop;
cfg = config.modules.desktop; # NOLINT(let_order)
in
{
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";

View File

@@ -6,7 +6,7 @@
}:
# The Hyprland compositor.
let
cfg = config.modules.desktop.session.hyprland;
cfg = config.modules.desktop.session.hyprland; # NOLINT(let_order)
user = config.user.name;
cursor = config.stylix.cursor;

View File

@@ -6,7 +6,7 @@
}:
# hypridle: idle-triggered locking and display power management.
let
cfg = config.modules.desktop.session.hyprland.hypridle;
cfg = config.modules.desktop.session.hyprland.hypridle; # NOLINT(let_order)
user = config.user.name;
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";

View File

@@ -6,7 +6,7 @@
}:
# The lock screen: a hyprlock session-lock surface the compositor owns.
let
cfg = config.modules.desktop.session.hyprland.hyprlock;
cfg = config.modules.desktop.session.hyprland.hyprlock; # NOLINT(let_order)
user = config.user.name;
# The hyprlock this module installs, used by the lock keybind below.

View File

@@ -7,7 +7,7 @@
# Text login: greetd running the tuigreet greeter, which starts the session
# through the universal Wayland session manager.
let
cfg = config.modules.desktop.session.login;
cfg = config.modules.desktop.session.login; # NOLINT(let_order)
in
{
options.modules.desktop.session.login.enable =

View File

@@ -6,7 +6,7 @@
}:
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
let
cfg = config.modules.desktop.session.mako;
cfg = config.modules.desktop.session.mako; # NOLINT(let_order)
user = config.user.name;
makoctl = "${pkgs.mako}/bin/makoctl";

View File

@@ -1,7 +1,7 @@
{ config, lib, ... }:
# XDG desktop portal routing for in-app screen sharing and file dialogs.
let
cfg = config.modules.desktop.session.portals;
cfg = config.modules.desktop.session.portals; # NOLINT(let_order)
in
{
options.modules.desktop.session.portals.enable =

View File

@@ -6,7 +6,7 @@
}:
# rofi as the desktop launcher and dmenu-style menu frontend.
let
cfg = config.modules.desktop.session.rofi;
cfg = config.modules.desktop.session.rofi; # NOLINT(let_order)
user = config.user.name;
# A session menu built on the same themed rofi, so the power actions look

View File

@@ -6,7 +6,7 @@
}:
# Waybar: a top status bar reading system state at a glance.
let
cfg = config.modules.desktop.session.waybar;
cfg = config.modules.desktop.session.waybar; # NOLINT(let_order)
vpnClient = config.modules.network.vpn-client;
user = config.user.name;

View File

@@ -1,7 +1,7 @@
{ config, lib, ... }:
# PipeWire as the desktop audio server.
let
cfg = config.modules.desktop.utilities.audio;
cfg = config.modules.desktop.utilities.audio; # NOLINT(let_order)
in
{
options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";

View File

@@ -6,7 +6,7 @@
}:
# Clipboard history: cliphist records every copy, picked back through rofi.
let
cfg = config.modules.desktop.utilities.clipboard;
cfg = config.modules.desktop.utilities.clipboard; # NOLINT(let_order)
user = config.user.name;
cliphist = "${pkgs.cliphist}/bin/cliphist";

View File

@@ -6,7 +6,7 @@
}:
# SwayOSD: a transient on-screen popup for volume and brightness changes.
let
cfg = config.modules.desktop.utilities.osd;
cfg = config.modules.desktop.utilities.osd; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
cfg = config.modules.desktop.utilities.recording;
cfg = config.modules.desktop.utilities.recording; # NOLINT(let_order)
user = config.user.name;
slurp = "${pkgs.slurp}/bin/slurp";

View File

@@ -7,7 +7,7 @@
# Keyboard-driven screenshots that open in satty for annotation, then land in
# both the clipboard and a dated file.
let
cfg = config.modules.desktop.utilities.screenshot;
cfg = config.modules.desktop.utilities.screenshot; # NOLINT(let_order)
user = config.user.name;
grimblast = "${pkgs.grimblast}/bin/grimblast";

View File

@@ -6,7 +6,7 @@
}:
# Nord theming for the graphical layer, driven from one base16 scheme by Stylix.
let
cfg = config.modules.desktop.utilities.theming;
cfg = config.modules.desktop.utilities.theming; # NOLINT(let_order)
user = config.user.name;
# A single static Nord wallpaper, drawn as a vertical gradient across the

View File

@@ -2,7 +2,7 @@
# Declares the XDG user directories, so a tool that queries them lands in a
# well-known folder a host can relocate from one place.
let
cfg = config.modules.desktop.utilities.userdirs;
cfg = config.modules.desktop.utilities.userdirs; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -1,6 +1,6 @@
{ config, lib, ... }:
let
cfg = config.modules.guest;
cfg = config.modules.guest; # NOLINT(let_order)
in
{
options.modules.guest.enable = lib.mkEnableOption "nested-guest base configuration";

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.modules.host;
cfg = config.modules.host; # NOLINT(let_order)
inherit (config) user;
hostSecrets = ../secrets + "/${config.networking.hostName}.yaml";
passwordSecret = "${user.name}-password";

View File

@@ -5,7 +5,7 @@
}:
# Tools for developing this monorepo.
let
cfg = config.modules.monorepo;
cfg = config.modules.monorepo; # NOLINT(let_order)
in
{
options.modules.monorepo.enable = lib.mkEnableOption "tools for developing this monorepo";

View File

@@ -6,7 +6,7 @@
}:
# SSH on this machine, in both directions.
let
cfg = config.modules.network.ssh;
cfg = config.modules.network.ssh; # NOLINT(let_order)
user = config.user.name;
inherit (lib)

View File

@@ -6,7 +6,7 @@
}:
# The host networking foundation: per-VLAN bridges over a tagged trunk.
let
cfg = config.modules.network.vlans;
cfg = config.modules.network.vlans; # NOLINT(let_order)
inherit (util) bridgeName;

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.modules.network.vpn-client;
cfg = config.modules.network.vpn-client; # NOLINT(let_order)
in
{
options.modules.network.vpn-client = {

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.modules.services.actualbudget;
cfg = config.modules.services.actualbudget; # NOLINT(let_order)
in
{
options.modules.services.actualbudget = {

View File

@@ -5,7 +5,7 @@
...
}:
let
cfg = config.modules.services.copyparty;
cfg = config.modules.services.copyparty; # NOLINT(let_order)
volumeType = lib.types.submodule {
options = {

View File

@@ -5,7 +5,7 @@
...
}:
let
cfg = config.modules.services.homepage;
cfg = config.modules.services.homepage; # NOLINT(let_order)
mkRegistryPath =
registryName: host: entry:

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.modules.services.navidrome;
cfg = config.modules.services.navidrome; # NOLINT(let_order)
in
{
options.modules.services.navidrome = {

View File

@@ -5,7 +5,7 @@
...
}:
let
cfg = config.modules.services.reverse-proxy;
cfg = config.modules.services.reverse-proxy; # NOLINT(let_order)
mkRegistryPath =
registryName: host: entry:

View File

@@ -6,7 +6,7 @@
}:
# Bazel for the primary user's shell environment.
let
cfg = config.modules.shell.bazel;
cfg = config.modules.shell.bazel; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -5,7 +5,7 @@
}:
# direnv for the primary user, configured through home-manager.
let
cfg = config.modules.shell.direnv;
cfg = config.modules.shell.direnv; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# fish for the primary user, configured through home-manager.
let
cfg = config.modules.shell.fish;
cfg = config.modules.shell.fish; # NOLINT(let_order)
user = config.user.name;
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
envVars = lib.attrByPath [ config.networking.hostName ] { } config.registry.env-var;

View File

@@ -5,7 +5,7 @@
}:
# git for the primary user, configured through home-manager.
let
cfg = config.modules.shell.git;
cfg = config.modules.shell.git; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# gitea-axi for the primary user, installed through its own home-manager module.
let
cfg = config.modules.shell.gitea-axi;
cfg = config.modules.shell.gitea-axi; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Herdr, a terminal multiplexer for coding agents.
let
cfg = config.modules.shell.herdr;
cfg = config.modules.shell.herdr; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -7,7 +7,7 @@
}:
# Neovim for the primary user, configured declaratively through nixvim.
let
cfg = config.modules.shell.nvim;
cfg = config.modules.shell.nvim; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Oh My Pi, installed alongside upstream Pi as the `omp` command.
let
cfg = config.modules.shell.omp;
cfg = config.modules.shell.omp; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -8,7 +8,7 @@
# home-manager, which ships the package and manages global settings.
# The login credential is left unmanaged, so it survives rebuilds.
let
cfg = config.modules.shell.pi;
cfg = config.modules.shell.pi; # NOLINT(let_order)
user = config.user.name;
piDir = "${config.users.users.${user}.home}/.pi/agent";
vendoredPi = pkgs.pi-coding-agent.overrideAttrs {

View File

@@ -6,7 +6,7 @@
}:
# Python tools for the primary user's shell environment.
let
cfg = config.modules.shell.python;
cfg = config.modules.shell.python; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Rust tools for the primary user's shell environment.
let
cfg = config.modules.shell.rust;
cfg = config.modules.shell.rust; # NOLINT(let_order)
user = config.user.name;
toolchain = pkgs.symlinkJoin {
name = "rust-toolchain";

View File

@@ -6,7 +6,7 @@
# tmux for the primary user, configured through home-manager.
# Settings without a home-manager option are read from ./extra.conf.
let
cfg = config.modules.shell.tmux;
cfg = config.modules.shell.tmux; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -6,7 +6,7 @@
}:
# Yazi, a terminal file manager.
let
cfg = config.modules.shell.yazi;
cfg = config.modules.shell.yazi; # NOLINT(let_order)
user = config.user.name;
in
{

View File

@@ -5,7 +5,7 @@
}:
# The host-level ZFS pool import: durable service state a host mounts, never rebuilds.
let
cfg = config.modules.zfs;
cfg = config.modules.zfs; # NOLINT(let_order)
in
{
options.modules.zfs = {

View File

@@ -111,7 +111,7 @@ pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> {
}
let graph = ScopeGraph::from_file(&parsed);
diagnostics.extend(rules::run_rules(&parsed, &graph));
diagnostics.extend(rules::run_rules(&parsed, &graph, &file.source));
}
Ok(Report {

View File

@@ -65,6 +65,18 @@ fn natu_reports_out_of_order_let_bindings() -> googletest::Result<()> {
Ok(())
}
#[gtest]
fn natu_preserves_suppressed_bindings() -> googletest::Result<()> {
let report = natu::future_sight(vec![SourceFile {
path: PathBuf::from("testdata/nolint.nix"),
source: "{ config, ... }: let\n cfg = config.example; # NOLINT(let_order)\n value = cfg;\nin value\n".into(),
}])
.or_fail()?;
expect_that!(report.is_clean(), is_true());
Ok(())
}
#[gtest]
fn natu_reports_unquoted_uri() -> googletest::Result<()> {
let report = natu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;

View File

@@ -59,7 +59,7 @@ pub trait TokenRule {
fn check(token: &Self::Token) -> Option<&'static str>;
}
pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph) -> Vec<Diagnostic> {
pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph, source: &str) -> Vec<Diagnostic> {
let node_diagnostics = file
.nodes
.iter()
@@ -76,17 +76,21 @@ pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph) -> Vec<Diagnostic>
.flat_map(|token| [run_token_rule::<UnquotedUri>(token)])
.flatten();
let let_order_diagnostics = let_order::check(graph).into_iter().map(|finding| Finding {
rule_id: "let_order",
range: finding.range,
message: "Let bindings must follow dependency order",
binding: None,
expected_order: Some(finding.expected_order),
});
let let_order_diagnostics =
let_order::check(graph, source)
.into_iter()
.map(|finding| Finding {
rule_id: "let_order",
range: finding.range,
message: "Let bindings must follow dependency order",
binding: None,
expected_order: Some(finding.expected_order),
});
node_diagnostics
.chain(token_diagnostics)
.chain(let_order_diagnostics)
.filter(|finding| !is_suppressed(source, finding.range, finding.rule_id))
.map(|finding| Diagnostic {
file_id: file.file_id,
finding,
@@ -94,6 +98,24 @@ pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph) -> Vec<Diagnostic>
.collect()
}
pub fn is_suppressed(source: &str, range: ast::SourceRange, rule_id: &str) -> bool {
let line = source[range.start..].lines().next().unwrap_or_default();
let Some(comment) = line.split_once('#').map(|(_, comment)| comment.trim()) else {
return false;
};
if comment == "NOLINT" {
return true;
}
comment
.strip_prefix("NOLINT(")
.and_then(|rules| rules.strip_suffix(')'))
.is_some_and(|rules| {
rules
.split(',')
.any(|candidate| candidate.trim() == rule_id)
})
}
fn run_node_rule<R>(node: &ast::Node) -> Option<Finding>
where
R: NodeRule,

View File

@@ -1,5 +1,6 @@
use crate::{
ast,
rules::is_suppressed,
scope_graph::{LetScope, NodeId, ScopeGraph},
};
@@ -8,36 +9,51 @@ pub struct Finding {
pub expected_order: Vec<String>,
}
pub fn check(graph: &ScopeGraph) -> Vec<Finding> {
pub fn check(graph: &ScopeGraph, source: &str) -> Vec<Finding> {
graph
.let_scopes()
.iter()
.flat_map(|let_scope| {
let expected = expected_order(graph, let_scope);
let is_ordered = expected.iter().enumerate().all(|(position, declaration)| {
let_scope
.bindings
.get(position)
.is_some_and(|binding| binding.declaration == *declaration)
});
let expected = expected_order(graph, let_scope)
.into_iter()
.filter(|declaration| !binding_suppressed(let_scope, *declaration, source))
.collect::<Vec<_>>();
let actual = let_scope
.bindings
.iter()
.filter(|binding| !is_suppressed(source, binding.range, "let_order"))
.map(|binding| binding.declaration)
.collect::<Vec<_>>();
(!is_ordered).then(|| Finding {
(!actual.starts_with(&expected)).then(|| Finding {
range: let_scope.range,
expected_order: expected
.iter()
.filter_map(|declaration| {
let_scope
.bindings
.iter()
.find(|binding| binding.declaration == *declaration)
.map(|binding| binding.name.clone())
})
.map(|declaration| binding_name(let_scope, *declaration).to_owned())
.collect(),
})
})
.collect()
}
fn binding_suppressed(let_scope: &LetScope, declaration: NodeId, source: &str) -> bool {
let binding = let_scope
.bindings
.iter()
.find(|binding| binding.declaration == declaration)
.expect("let declaration");
is_suppressed(source, binding.range, "let_order")
}
fn binding_name(let_scope: &LetScope, declaration: NodeId) -> &str {
&let_scope
.bindings
.iter()
.find(|binding| binding.declaration == declaration)
.expect("let declaration")
.name
}
fn expected_order(graph: &ScopeGraph, let_scope: &LetScope) -> Vec<NodeId> {
let mut order = Vec::new();
visit_references(graph, &let_scope.entry_references, let_scope, &mut order);

View File

@@ -64,6 +64,7 @@ impl ScopeGraph {
declaration: self.add_declaration(&binding.key.name, scope),
name: binding.key.name.clone(),
references: Vec::new(),
range: binding.range,
})
.collect::<Vec<_>>();
@@ -196,4 +197,5 @@ pub(crate) struct Binding {
pub(crate) declaration: NodeId,
pub(crate) name: String,
pub(crate) references: Vec<NodeId>,
pub(crate) range: ast::SourceRange,
}