From e5b46dd45c8564abf1c8c2f4059613304bc64b58 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Thu, 22 Jan 2026 16:09:00 -0800 Subject: [PATCH 1/5] Add 'nudge check' command for CI/linter usage Validates project files against configured rules, enabling use in CI pipelines or as a standalone linter. Usage: nudge check # Check all files nudge check src/ # Check specific directory nudge check "**/*.rs" # Check files matching pattern Exits 0 on success, 1 when issues found. Output is compact and human-readable, showing file:line locations and rule violations. --- Cargo.lock | 40 +++++ packages/nudge/Cargo.toml | 1 + packages/nudge/src/cmd.rs | 1 + packages/nudge/src/cmd/check.rs | 294 ++++++++++++++++++++++++++++++++ packages/nudge/src/main.rs | 4 + 5 files changed, 340 insertions(+) create mode 100644 packages/nudge/src/cmd/check.rs diff --git a/Cargo.lock b/Cargo.lock index 72da5a1..2e1657f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,16 @@ dependencies = [ "syn", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -507,6 +517,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -525,6 +548,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indent" version = "0.1.1" @@ -704,6 +743,7 @@ dependencies = [ "derive_more", "directories", "glob", + "ignore", "indoc", "itertools", "monostate", diff --git a/packages/nudge/Cargo.toml b/packages/nudge/Cargo.toml index 16011da..aa49ced 100644 --- a/packages/nudge/Cargo.toml +++ b/packages/nudge/Cargo.toml @@ -35,6 +35,7 @@ tree-sitter-haskell = "0.23.1" derive_more = { version = "2.1.0", features = ["full"] } itertools = "0.14.0" indoc = "2.0.7" +ignore = "0.4.25" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/packages/nudge/src/cmd.rs b/packages/nudge/src/cmd.rs index bc9ac5f..93e30ba 100644 --- a/packages/nudge/src/cmd.rs +++ b/packages/nudge/src/cmd.rs @@ -1,5 +1,6 @@ //! Commands for the binary. +pub mod check; pub mod claude; pub mod syntaxtree; pub mod test; diff --git a/packages/nudge/src/cmd/check.rs b/packages/nudge/src/cmd/check.rs new file mode 100644 index 0000000..f877db7 --- /dev/null +++ b/packages/nudge/src/cmd/check.rs @@ -0,0 +1,294 @@ +//! Check project files against configured rules. +//! +//! This command validates all files in the project against Nudge rules, +//! enabling use in CI pipelines or as a standalone linter. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use clap::Args; +use color_eyre::eyre::{Context, Result}; +use glob::Pattern; +use ignore::WalkBuilder; + +use nudge::rules::{self, GlobMatcher, Hook, PreToolUseMatcher, Rule}; + +#[derive(Args, Clone, Debug)] +pub struct Config { + /// Paths or glob patterns to check. If not specified, checks entire project. + #[arg()] + pub paths: Vec, +} + +/// An issue found during checking. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Issue { + /// Path to the file containing the issue. + file: PathBuf, + /// Line number (1-indexed) where the issue starts. + line: usize, + /// Name of the rule that was violated. + rule_name: String, + /// Message describing the issue. + message: String, +} + +/// A file pattern extracted from a rule, with the matchers to apply. +struct FileRule<'a> { + /// The glob pattern for matching files. + pattern: &'a GlobMatcher, + /// The content matchers to apply. + matchers: ContentMatcherSet<'a>, + /// Reference to the parent rule. + rule: &'a Rule, +} + +/// Content matchers extracted from a rule hook. +enum ContentMatcherSet<'a> { + Write(&'a [nudge::rules::ContentMatcher]), + Edit(&'a [nudge::rules::ContentMatcher]), +} + +pub fn main(config: Config) -> Result<()> { + let rules_by_source = rules::load_all_attributed().context("load rules")?; + + // Collect file rules from all sources + let mut file_rules = Vec::new(); + let mut total_rules = 0; + + for (_source, rules) in &rules_by_source { + for rule in rules { + total_rules += 1; + // Extract Write hooks + for hook in &rule.on { + match hook { + Hook::PreToolUse(PreToolUseMatcher::Write(matcher)) => { + file_rules.push(FileRule { + pattern: &matcher.file, + matchers: ContentMatcherSet::Write(&matcher.content), + rule, + }); + } + Hook::PreToolUse(PreToolUseMatcher::Edit(matcher)) => { + file_rules.push(FileRule { + pattern: &matcher.file, + matchers: ContentMatcherSet::Edit(&matcher.new_content), + rule, + }); + } + // Skip WebFetch, Bash, and UserPromptSubmit - they don't apply to file content + _ => {} + } + } + } + } + + if file_rules.is_empty() { + println!("No file-based rules found."); + return Ok(()); + } + + // Walk the project and collect files to check + let files = collect_files(&config.paths)?; + + // Check each file against matching rules + // Use a HashSet to deduplicate issues (same rule can have Write and Edit hooks + // with identical matchers) + let mut issues_set = HashSet::new(); + let mut checked_files = 0; + + for file in &files { + let mut file_checked = false; + + for file_rule in &file_rules { + if !file_rule.pattern.is_match_path(file) { + continue; + } + + // Read file content + let content = match std::fs::read_to_string(file) { + Ok(c) => c, + Err(e) => { + tracing::debug!(?file, error = %e, "skipping file (could not read)"); + continue; + } + }; + + file_checked = true; + + // Get the matchers based on hook type + let matchers = match &file_rule.matchers { + ContentMatcherSet::Write(m) => *m, + ContentMatcherSet::Edit(m) => *m, + }; + + // Check content against matchers + // A rule matches if ALL matchers match (AND logic), so we need to check + // if all matchers have at least one match + let all_matched = matchers.iter().all(|m| m.is_match(&content)); + + if all_matched && !matchers.is_empty() { + // Collect matches from all matchers for reporting + for matcher in matchers { + let matches = matcher.matches_with_context(&content); + for m in matches { + let line = byte_offset_to_line(&content, m.span.start); + let message = + nudge::template::interpolate(&file_rule.rule.message, &m.captures); + issues_set.insert(Issue { + file: file.clone(), + line, + rule_name: file_rule.rule.name.clone(), + message, + }); + } + } + } + } + + if file_checked { + checked_files += 1; + } + } + + // Convert to sorted Vec for deterministic output + let mut issues: Vec<_> = issues_set.into_iter().collect(); + issues.sort_by(|a, b| { + a.file + .cmp(&b.file) + .then(a.line.cmp(&b.line)) + .then(a.rule_name.cmp(&b.rule_name)) + }); + + // Output results + if issues.is_empty() { + print_success(checked_files, total_rules, &rules_by_source); + Ok(()) + } else { + print_failure(&issues, checked_files, total_rules); + std::process::exit(1); + } +} + +/// Collect files to check based on provided paths or entire project. +fn collect_files(paths: &[PathBuf]) -> Result> { + let mut files = Vec::new(); + + if paths.is_empty() { + // Walk entire project from current directory + for entry in WalkBuilder::new(".").hidden(false).build() { + let entry = entry.context("walk directory")?; + if entry.file_type().is_some_and(|ft| ft.is_file()) { + files.push(entry.into_path()); + } + } + } else { + // Process each provided path + for path in paths { + let path_str = path.to_string_lossy(); + + // Check if it's a glob pattern + if path_str.contains('*') || path_str.contains('?') || path_str.contains('[') { + let pattern = Pattern::new(&path_str) + .with_context(|| format!("invalid glob pattern: {path_str}"))?; + + // Walk from current directory and filter by pattern + for entry in WalkBuilder::new(".").hidden(false).build() { + let entry = entry.context("walk directory")?; + if entry.file_type().is_some_and(|ft| ft.is_file()) + && pattern.matches_path(entry.path()) + { + files.push(entry.into_path()); + } + } + } else if path.is_dir() { + // Walk the directory + for entry in WalkBuilder::new(path).hidden(false).build() { + let entry = entry.context("walk directory")?; + if entry.file_type().is_some_and(|ft| ft.is_file()) { + files.push(entry.into_path()); + } + } + } else if path.is_file() { + files.push(path.clone()); + } else { + tracing::warn!(?path, "path does not exist, skipping"); + } + } + } + + Ok(files) +} + +/// Convert a byte offset to a 1-indexed line number. +fn byte_offset_to_line(content: &str, offset: usize) -> usize { + content[..offset.min(content.len())] + .chars() + .filter(|&c| c == '\n') + .count() + + 1 +} + +/// Print success message. +fn print_success( + checked_files: usize, + total_rules: usize, + rules_by_source: &[(PathBuf, Vec)], +) { + println!( + "\u{2713} Checked {} files against {} rules", + checked_files, total_rules + ); + for (source, rules) in rules_by_source { + if !rules.is_empty() { + println!( + " - {}: {} {}", + source.display(), + rules.len(), + if rules.len() == 1 { "rule" } else { "rules" } + ); + } + } +} + +/// Print failure message with issues. +fn print_failure(issues: &[Issue], checked_files: usize, total_rules: usize) { + // Group issues by file for cleaner output + let mut issues_by_file: HashMap<&Path, Vec<&Issue>> = HashMap::new(); + for issue in issues { + issues_by_file.entry(&issue.file).or_default().push(issue); + } + + let file_count = issues_by_file.len(); + println!( + "\u{2717} Found {} {} in {} {}", + issues.len(), + if issues.len() == 1 { "issue" } else { "issues" }, + file_count, + if file_count == 1 { "file" } else { "files" } + ); + println!(); + + // Sort files for deterministic output + let mut files: Vec<_> = issues_by_file.keys().collect(); + files.sort(); + + for file in files { + let file_issues = &issues_by_file[file]; + for issue in file_issues { + println!( + "{}:{} [{}]", + issue.file.display(), + issue.line, + issue.rule_name + ); + println!(" {}", issue.message); + println!(); + } + } + + println!( + "Checked {} files against {} rules", + checked_files, total_rules + ); +} diff --git a/packages/nudge/src/main.rs b/packages/nudge/src/main.rs index 30e9f0a..67e194a 100644 --- a/packages/nudge/src/main.rs +++ b/packages/nudge/src/main.rs @@ -19,6 +19,9 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Check project files against configured rules. + Check(cmd::check::Config), + /// Integration with Claude Code. Claude(cmd::claude::Config), @@ -73,6 +76,7 @@ fn main() -> Result<()> { // that users or claude code can see an error and then run the command to // learn more about debugging nudge. match cli.command { + Commands::Check(config) => cmd::check::main(config), Commands::Claude(config) => cmd::claude::main(config), Commands::Syntaxtree(config) => cmd::syntaxtree::main(config), Commands::Validate(config) => cmd::validate::main(config), From b1717c612a4d2d83d69e95af824749e4a01e3dd9 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Thu, 22 Jan 2026 16:51:10 -0800 Subject: [PATCH 2/5] Fix rule violations detected by nudge check Apply fixes for real violations found by running nudge check on the codebase: - Replace std::fs::*, std::process::*, std::fmt::*, etc. with imports - Use turbofish syntax instead of LHS type annotations where possible - Move inline imports to file top (std::sync::Mutex in schema.rs) - Add pretty_assertions import to test modules and convert assert_eq! Remaining violations are false positives due to rule pattern issues: - prefer-pretty-assertions: pattern matches substring in pretty_assert_eq! - no-lhs-type-annotations: matches pattern matching (let Value::Object) and required type annotations on generic functions (serde) - no-inline-imports: matches idiomatic use super::* in test modules - no-unwrap: matches .unwrap() in doc comments and string literals --- packages/benchmark/src/agent.rs | 27 +++++++++-------- packages/benchmark/src/cmd/syntax.rs | 3 +- packages/benchmark/src/outcome.rs | 12 ++++---- packages/benchmark/src/scenario.rs | 5 ++-- packages/nudge/build.rs | 8 ++++-- packages/nudge/src/cmd/check.rs | 12 ++++---- packages/nudge/src/cmd/claude/hook.rs | 3 +- packages/nudge/src/cmd/claude/run/stream.rs | 6 ++-- packages/nudge/src/cmd/claude/setup.rs | 3 +- packages/nudge/src/cmd/syntaxtree.rs | 3 +- packages/nudge/src/cmd/test.rs | 3 +- packages/nudge/src/rules/schema.rs | 32 ++++++++++----------- packages/nudge/src/template.rs | 16 ++++++----- packages/nudge/tests/it/bash.rs | 5 ++-- packages/nudge/tests/it/external.rs | 3 +- packages/nudge/tests/it/syntax_tree.rs | 3 +- 16 files changed, 82 insertions(+), 62 deletions(-) diff --git a/packages/benchmark/src/agent.rs b/packages/benchmark/src/agent.rs index 058ef1e..627d7c5 100644 --- a/packages/benchmark/src/agent.rs +++ b/packages/benchmark/src/agent.rs @@ -1,6 +1,10 @@ //! Agents that are configured to be evaluated by the benchmark. +use std::fmt::{Display, Formatter}; +use std::fs; use std::path::Path; +use std::process::Command; +use std::str::FromStr; use clap::ValueEnum; use color_eyre::{ @@ -18,7 +22,7 @@ pub enum Agent { ClaudeCode(ModelClaudeCode), } -impl std::str::FromStr for Agent { +impl FromStr for Agent { type Err = String; /// Parse an agent from a string like `claude-code:sonnet` or @@ -40,8 +44,8 @@ impl std::str::FromStr for Agent { } } -impl std::fmt::Display for Agent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for Agent { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Agent::ClaudeCode(model) => write!(f, "Claude Code ({model})"), } @@ -69,7 +73,7 @@ impl Agent { #[tracing::instrument] pub fn run(&self, project: &Path, prompt: &str) -> Result<()> { match self { - Agent::ClaudeCode(model) => std::process::Command::new("claude") + Agent::ClaudeCode(model) => Command::new("claude") .args(["--permission-mode", "acceptEdits"]) .args(["--allowedTools", "Write,Edit,Read,Glob,Grep"]) .args(["--tools", "Write,Edit,Read,Glob,Grep"]) @@ -113,8 +117,7 @@ impl Agent { Agent::ClaudeCode(_) => { let target = project.join("CLAUDE.md"); tracing::debug!(?target, "writing claude guidance"); - std::fs::write(&target, context) - .with_context(|| format!("write context to {target:?}")) + fs::write(&target, context).with_context(|| format!("write context to {target:?}")) } } } @@ -123,7 +126,7 @@ impl Agent { #[tracing::instrument] pub fn configure_nudge(&self, project: &Path) -> Result<()> { match self { - Agent::ClaudeCode(_) => std::process::Command::new("nudge") + Agent::ClaudeCode(_) => Command::new("nudge") .arg("claude") .arg("setup") .current_dir(project) @@ -164,7 +167,7 @@ pub enum ModelClaudeCode { Custom(String), } -impl std::str::FromStr for ModelClaudeCode { +impl FromStr for ModelClaudeCode { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { @@ -177,8 +180,8 @@ impl std::str::FromStr for ModelClaudeCode { } } -impl std::fmt::Display for ModelClaudeCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for ModelClaudeCode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::SonnetLatest => write!(f, "Sonnet"), Self::HaikuLatest => write!(f, "Haiku"), @@ -220,8 +223,8 @@ pub enum Guidance { File, } -impl std::fmt::Display for Guidance { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for Guidance { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::None => write!(f, "None"), Self::Nudge => write!(f, "Nudge"), diff --git a/packages/benchmark/src/cmd/syntax.rs b/packages/benchmark/src/cmd/syntax.rs index 0f3796a..d2fd259 100644 --- a/packages/benchmark/src/cmd/syntax.rs +++ b/packages/benchmark/src/cmd/syntax.rs @@ -1,5 +1,6 @@ //! Display the syntax tree for a code snippet. +use std::fs; use std::path::Path; use benchmark::{matcher::code::Language, snippet::Snippet}; @@ -28,7 +29,7 @@ pub fn main(config: Config) -> Result<()> { fn resolve_input(input: &str) -> String { let path = Path::new(input); if path.exists() { - std::fs::read_to_string(path).unwrap_or_else(|_| input.to_string()) + fs::read_to_string(path).unwrap_or_else(|_| input.to_string()) } else { input.to_string() } diff --git a/packages/benchmark/src/outcome.rs b/packages/benchmark/src/outcome.rs index 74a62a9..b2165df 100644 --- a/packages/benchmark/src/outcome.rs +++ b/packages/benchmark/src/outcome.rs @@ -107,8 +107,8 @@ impl Outcome { } } -impl std::fmt::Display for Outcome { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for Outcome { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Pass { evidence } => { writeln!(f, "{}", cformat!("✓ Passed"))?; @@ -275,8 +275,8 @@ pub enum Violation { QueryMatched(QueryMatchedViolation), } -impl std::fmt::Display for Violation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for Violation { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::CommandFailed(failed) => failed.fmt(f), Self::QueryNotMatched(not_matched) => not_matched.fmt(f), @@ -299,8 +299,8 @@ pub enum Evidence { QueryNotMatched(QueryNotMatchedEvidence), } -impl std::fmt::Display for Evidence { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for Evidence { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::CommandSucceeded(succeeded) => succeeded.fmt(f), Self::QueryMatched(matched) => matched.fmt(f), diff --git a/packages/benchmark/src/scenario.rs b/packages/benchmark/src/scenario.rs index bb61019..11b08af 100644 --- a/packages/benchmark/src/scenario.rs +++ b/packages/benchmark/src/scenario.rs @@ -12,6 +12,7 @@ use std::{ fmt::{self, Display, Formatter}, fs::{create_dir_all, read_to_string, write}, path::{Path, PathBuf}, + process, }; use bon::Builder; @@ -412,7 +413,7 @@ impl Command { #[tracing::instrument] pub fn run(&self, project: &Path) -> Result<()> { tracing::debug!("running command"); - std::process::Command::new(&self.binary) + process::Command::new(&self.binary) .args(&self.args) .current_dir(project) .output() @@ -443,7 +444,7 @@ impl Command { /// error. #[tracing::instrument] pub fn evaluate(&self, project: &Path) -> Result { - let output = std::process::Command::new(&self.binary) + let output = process::Command::new(&self.binary) .args(&self.args) .current_dir(project) .tap(|cmd| tracing::debug!(?cmd, "running evaluation command")) diff --git a/packages/nudge/build.rs b/packages/nudge/build.rs index 629a8bf..11d42a4 100644 --- a/packages/nudge/build.rs +++ b/packages/nudge/build.rs @@ -4,7 +4,9 @@ //! - Uses `git describe --always` to get the base version (tag or commit hash) //! - If the working tree is dirty, appends a content hash of the changed files +use std::fs; use std::hash::{DefaultHasher, Hasher as _}; +use std::iter; use std::path::Path; use std::process::Command; use std::str::FromStr; @@ -41,7 +43,7 @@ fn content_hash(mut files: Vec) -> Result { let path = Path::new(&repo_root).join(file.path); let mut hasher = DefaultHasher::new(); #[allow(clippy::disallowed_methods)] - if let Ok(content) = std::fs::read(&path) { + if let Ok(content) = fs::read(&path) { hasher.write(path.as_os_str().as_encoded_bytes()); hasher.write(&content); let hash = hasher.finish(); @@ -61,7 +63,7 @@ fn content_hash(mut files: Vec) -> Result { } fn run(prog: &str, argv: &[&str]) -> Result { - let invocation = std::iter::once(prog) + let invocation = iter::once(prog) .chain(argv.iter().copied()) .collect::>() .join(" "); @@ -158,7 +160,7 @@ impl FromStr for StatusEntry { let worktree = GitFileStatus::parse(worktree_char) .ok_or_else(|| format!("invalid worktree status: {worktree_char}"))?; - let rest: String = chars.collect(); + let rest = chars.collect::(); let (path, orig_path) = if matches!(index, GitFileStatus::Renamed | GitFileStatus::Copied) { if let Some((old, new)) = rest.split_once(" -> ") { (new.to_string(), Some(old.to_string())) diff --git a/packages/nudge/src/cmd/check.rs b/packages/nudge/src/cmd/check.rs index f877db7..887a856 100644 --- a/packages/nudge/src/cmd/check.rs +++ b/packages/nudge/src/cmd/check.rs @@ -4,7 +4,9 @@ //! enabling use in CI pipelines or as a standalone linter. use std::collections::{HashMap, HashSet}; +use std::fs; use std::path::{Path, PathBuf}; +use std::process; use clap::Args; use color_eyre::eyre::{Context, Result}; @@ -106,7 +108,7 @@ pub fn main(config: Config) -> Result<()> { } // Read file content - let content = match std::fs::read_to_string(file) { + let content = match fs::read_to_string(file) { Ok(c) => c, Err(e) => { tracing::debug!(?file, error = %e, "skipping file (could not read)"); @@ -152,7 +154,7 @@ pub fn main(config: Config) -> Result<()> { } // Convert to sorted Vec for deterministic output - let mut issues: Vec<_> = issues_set.into_iter().collect(); + let mut issues = issues_set.into_iter().collect::>(); issues.sort_by(|a, b| { a.file .cmp(&b.file) @@ -166,7 +168,7 @@ pub fn main(config: Config) -> Result<()> { Ok(()) } else { print_failure(&issues, checked_files, total_rules); - std::process::exit(1); + process::exit(1); } } @@ -254,7 +256,7 @@ fn print_success( /// Print failure message with issues. fn print_failure(issues: &[Issue], checked_files: usize, total_rules: usize) { // Group issues by file for cleaner output - let mut issues_by_file: HashMap<&Path, Vec<&Issue>> = HashMap::new(); + let mut issues_by_file = HashMap::<&Path, Vec<&Issue>>::new(); for issue in issues { issues_by_file.entry(&issue.file).or_default().push(issue); } @@ -270,7 +272,7 @@ fn print_failure(issues: &[Issue], checked_files: usize, total_rules: usize) { println!(); // Sort files for deterministic output - let mut files: Vec<_> = issues_by_file.keys().collect(); + let mut files = issues_by_file.keys().collect::>(); files.sort(); for file in files { diff --git a/packages/nudge/src/cmd/claude/hook.rs b/packages/nudge/src/cmd/claude/hook.rs index 354e318..501003f 100644 --- a/packages/nudge/src/cmd/claude/hook.rs +++ b/packages/nudge/src/cmd/claude/hook.rs @@ -1,5 +1,6 @@ //! Responds to Claude Code hooks. +use std::io; use std::iter::repeat; use clap::Args; @@ -23,7 +24,7 @@ pub struct Config {} #[instrument] pub fn main(_config: Config) -> Result<()> { - let stdin = std::io::stdin(); + let stdin = io::stdin(); let hook = serde_json::from_reader::<_, Hook>(stdin).context("read hook event")?; let rules = rules::load_all().context("load rules")?; diff --git a/packages/nudge/src/cmd/claude/run/stream.rs b/packages/nudge/src/cmd/claude/run/stream.rs index f349943..f06d37e 100644 --- a/packages/nudge/src/cmd/claude/run/stream.rs +++ b/packages/nudge/src/cmd/claude/run/stream.rs @@ -1,5 +1,7 @@ //! NDJSON message types for Claude Code's stream-json format. +use std::fmt::{Display, Formatter}; + use serde::{Deserialize, Serialize}; /// Output message from Claude Code (stdout). @@ -139,8 +141,8 @@ pub enum ToolResultContent { Structured(serde_json::Value), } -impl std::fmt::Display for ToolResultContent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for ToolResultContent { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { ToolResultContent::Text(s) => write!(f, "{}", s), ToolResultContent::Structured(v) => { diff --git a/packages/nudge/src/cmd/claude/setup.rs b/packages/nudge/src/cmd/claude/setup.rs index 329fcb0..f1fab2c 100644 --- a/packages/nudge/src/cmd/claude/setup.rs +++ b/packages/nudge/src/cmd/claude/setup.rs @@ -1,5 +1,6 @@ //! Set up Nudge hooks for Claude Code. +use std::env; use std::fs; use std::io::{self, BufRead, Write}; use std::path::PathBuf; @@ -46,7 +47,7 @@ pub fn main(config: Config) -> Result<()> { let settings_file = dotclaude.join("settings.local.json"); tracing::debug!(?dotclaude, ?settings_file, "read existing settings"); - let nudge_path = std::env::current_exe() + let nudge_path = env::current_exe() .context("get current executable path")? .to_str() .ok_or_eyre("convert current executable path to string")? diff --git a/packages/nudge/src/cmd/syntaxtree.rs b/packages/nudge/src/cmd/syntaxtree.rs index 44169e0..219149c 100644 --- a/packages/nudge/src/cmd/syntaxtree.rs +++ b/packages/nudge/src/cmd/syntaxtree.rs @@ -3,6 +3,7 @@ //! Useful for understanding tree structure when writing tree-sitter queries. //! Shows node kinds (what you match in queries) and field names. +use std::fs; use std::path::Path; use clap::Args; @@ -41,7 +42,7 @@ pub fn main(config: Config) -> Result<()> { fn resolve_input(input: &str) -> String { let path = Path::new(input); if path.exists() { - std::fs::read_to_string(path).unwrap_or_else(|_| input.to_string()) + fs::read_to_string(path).unwrap_or_else(|_| input.to_string()) } else { input.to_string() } diff --git a/packages/nudge/src/cmd/test.rs b/packages/nudge/src/cmd/test.rs index 5f9cbd1..97164aa 100644 --- a/packages/nudge/src/cmd/test.rs +++ b/packages/nudge/src/cmd/test.rs @@ -1,5 +1,6 @@ //! Test rules against sample input. +use std::fs; use std::path::PathBuf; use clap::Args; @@ -175,7 +176,7 @@ fn build_tool_use_hook(config: &Config) -> Result { // Get content from --content or --content-file let content = match (&config.content, &config.content_file) { (Some(c), _) => c.clone(), - (_, Some(path)) => std::fs::read_to_string(path) + (_, Some(path)) => fs::read_to_string(path) .with_context(|| format!("failed to read content from {}", path.display()))?, _ => String::new(), }; diff --git a/packages/nudge/src/rules/schema.rs b/packages/nudge/src/rules/schema.rs index de7c1d9..d187e60 100644 --- a/packages/nudge/src/rules/schema.rs +++ b/packages/nudge/src/rules/schema.rs @@ -4,7 +4,7 @@ use std::{ io::Write, path::Path, process::{Command, Stdio}, - sync::LazyLock, + sync::{LazyLock, Mutex}, }; use derive_more::Display; @@ -1023,8 +1023,6 @@ impl Language { /// don't block on parse errors since code being written is often /// incomplete. pub fn parse(self, source: &str) -> Option { - use std::sync::Mutex; - // Reuse parsers across calls. Parser creation has non-trivial overhead, // and parsers are designed to be reused. We use Mutex because parsing // is stateful (the parser tracks incremental parse state). @@ -1191,6 +1189,8 @@ impl<'de> Deserialize<'de> for TreeSitterQuery { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq as pretty_assert_eq; + use super::*; #[test] @@ -1262,7 +1262,7 @@ mod tests { }; let code = "fn foo() {}\nfn bar() {}"; let spans = matcher.matches(code); - assert_eq!(spans.len(), 2); + pretty_assert_eq!(spans.len(), 2); } #[test] @@ -1278,8 +1278,8 @@ mod tests { }; let code = "fn my_function() {}"; let matches = matcher.matches_with_context(code); - assert_eq!(matches.len(), 1); - assert_eq!( + pretty_assert_eq!(matches.len(), 1); + pretty_assert_eq!( matches[0].captures.get("fn_name"), Some(&"my_function".to_string()) ); @@ -1298,8 +1298,8 @@ mod tests { }; let code = "fn x() {}"; let matches = matcher.matches_with_context(code); - assert_eq!(matches.len(), 1); - assert_eq!( + pretty_assert_eq!(matches.len(), 1); + pretty_assert_eq!( matches[0].captures.get("suggestion"), Some(&"Rename x to something descriptive".to_string()) ); @@ -1355,7 +1355,7 @@ mod tests { }; let code = "fn foo() { let x = 1; }"; let spans = matcher.matches(code); - assert_eq!(spans.len(), 1); + pretty_assert_eq!(spans.len(), 1); // The span should cover from "foo" through the end of the block let matched_text = &code[spans[0].start..spans[0].end]; assert!(matched_text.contains("foo")); @@ -1407,8 +1407,8 @@ mod tests { command: vec!["false".to_string()], }; let matches = matcher.matches_with_context("content"); - assert_eq!(matches.len(), 1); - assert_eq!( + pretty_assert_eq!(matches.len(), 1); + pretty_assert_eq!( matches[0].captures.get("command"), Some(&"false".to_string()) ); @@ -1426,9 +1426,9 @@ mod tests { ], }; let matches = matcher.matches_with_context("content"); - assert_eq!(matches.len(), 1); + pretty_assert_eq!(matches.len(), 1); // shell_words::join formats the command - assert_eq!( + pretty_assert_eq!( matches[0].captures.get("command"), Some(&"test 1 -eq 0".to_string()) ); @@ -1457,7 +1457,7 @@ mod tests { pattern: "git\\s+push" "#; let matcher: PreToolUseBashMatcher = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(matcher.command.len(), 1); + pretty_assert_eq!(matcher.command.len(), 1); assert!(matcher.project_state.is_empty()); } @@ -1476,8 +1476,8 @@ mod tests { pattern: "^main$" "#; let matcher: PreToolUseBashMatcher = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(matcher.command.len(), 1); - assert_eq!(matcher.project_state.len(), 1); + pretty_assert_eq!(matcher.command.len(), 1); + pretty_assert_eq!(matcher.project_state.len(), 1); } #[test] diff --git a/packages/nudge/src/template.rs b/packages/nudge/src/template.rs index ea1f045..1c00f10 100644 --- a/packages/nudge/src/template.rs +++ b/packages/nudge/src/template.rs @@ -50,6 +50,8 @@ pub fn interpolate(template: &str, captures: &Captures) -> String { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq as pretty_assert_eq; + use super::*; #[test] @@ -62,7 +64,7 @@ mod tests { "Replace {{ $1 }}.unwrap() with {{ $1 }}.expect()", &captures, ); - assert_eq!(result, "Replace foo.unwrap() with foo.expect()"); + pretty_assert_eq!(result, "Replace foo.unwrap() with foo.expect()"); } #[test] @@ -72,14 +74,14 @@ mod tests { captures.insert("type".to_string(), "String".to_string()); let result = interpolate("Variable {{ $var }} has type {{ $type }}", &captures); - assert_eq!(result, "Variable x has type String"); + pretty_assert_eq!(result, "Variable x has type String"); } #[test] fn test_missing_capture_left_asis() { let captures = Captures::new(); let result = interpolate("Missing {{ $1 }} here", &captures); - assert_eq!(result, "Missing {{ $1 }} here"); + pretty_assert_eq!(result, "Missing {{ $1 }} here"); } #[test] @@ -91,7 +93,7 @@ mod tests { ); let result = interpolate("Don't use .unwrap(). {{ $suggestion }}", &captures); - assert_eq!(result, "Don't use .unwrap(). use .expect() instead"); + pretty_assert_eq!(result, "Don't use .unwrap(). use .expect() instead"); } #[test] @@ -102,14 +104,14 @@ mod tests { captures.insert("var".to_string(), "foo".to_string()); let result = interpolate("let Some({{ $var }}) = {{ $var }} else { ... }", &captures); - assert_eq!(result, "let Some(foo) = foo else { ... }"); + pretty_assert_eq!(result, "let Some(foo) = foo else { ... }"); } #[test] fn test_empty_template() { let captures = Captures::new(); let result = interpolate("", &captures); - assert_eq!(result, ""); + pretty_assert_eq!(result, ""); } #[test] @@ -118,6 +120,6 @@ mod tests { captures.insert("1".to_string(), "foo".to_string()); let result = interpolate("No placeholders here", &captures); - assert_eq!(result, "No placeholders here"); + pretty_assert_eq!(result, "No placeholders here"); } } diff --git a/packages/nudge/tests/it/bash.rs b/packages/nudge/tests/it/bash.rs index cf1dac7..8f793ef 100644 --- a/packages/nudge/tests/it/bash.rs +++ b/packages/nudge/tests/it/bash.rs @@ -1,5 +1,6 @@ //! Integration tests for Bash tool matching with project_state. +use std::fs; use std::io::Write as _; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -12,7 +13,7 @@ use tempfile::TempDir; fn setup_config(rules_yaml: &str) -> TempDir { let dir = TempDir::new().expect("create temp dir"); let config_path = dir.path().join(".nudge.yaml"); - std::fs::write(&config_path, rules_yaml).expect("write config"); + fs::write(&config_path, rules_yaml).expect("write config"); dir } @@ -42,7 +43,7 @@ fn setup_git_repo(branch_name: &str, rules_yaml: &str) -> TempDir { .expect("git config name"); // Create initial commit so we have a branch - std::fs::write(temp_path.join("README.md"), "# Test").expect("write readme"); + fs::write(temp_path.join("README.md"), "# Test").expect("write readme"); Command::new("git") .args(["add", "."]) .current_dir(temp_path) diff --git a/packages/nudge/tests/it/external.rs b/packages/nudge/tests/it/external.rs index 17ee536..e5c716c 100644 --- a/packages/nudge/tests/it/external.rs +++ b/packages/nudge/tests/it/external.rs @@ -4,6 +4,7 @@ //! hook pipeline, including correct command execution and template //! interpolation. +use std::fs; use std::io::Write as _; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -16,7 +17,7 @@ use tempfile::TempDir; fn setup_config(rules_yaml: &str) -> TempDir { let dir = TempDir::new().expect("create temp dir"); let config_path = dir.path().join(".nudge.yaml"); - std::fs::write(&config_path, rules_yaml).expect("write config"); + fs::write(&config_path, rules_yaml).expect("write config"); dir } diff --git a/packages/nudge/tests/it/syntax_tree.rs b/packages/nudge/tests/it/syntax_tree.rs index 1f70f9d..324de9e 100644 --- a/packages/nudge/tests/it/syntax_tree.rs +++ b/packages/nudge/tests/it/syntax_tree.rs @@ -14,6 +14,7 @@ mod python; mod typescript_errors; mod typescript_types; +use std::fs; use std::io::Write as _; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -26,7 +27,7 @@ use tempfile::TempDir; fn setup_config(rules_yaml: &str) -> TempDir { let dir = TempDir::new().expect("create temp dir"); let config_path = dir.path().join(".nudge.yaml"); - std::fs::write(&config_path, rules_yaml).expect("write config"); + fs::write(&config_path, rules_yaml).expect("write config"); dir } From 62fa698f22affc233f104892903991e414686375 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Thu, 22 Jan 2026 17:05:05 -0800 Subject: [PATCH 3/5] Replace .unwrap() with .expect() and update rules to use tree-sitter - Convert 4 rules from regex to tree-sitter to eliminate false positives: - prefer-pretty-assertions: matches macro_invocation instead of substring - no-lhs-type-annotations: matches let_declaration with type annotation - no-inline-imports: matches use inside block, not test modules - no-unwrap: matches actual method calls, avoiding doc comments/strings - Replace all .unwrap() calls with .expect() for better error messages - 11 fixes in schema.rs test code - 1 fix in rules.rs test code - 2 fixes in snippet.rs test code - 9 fixes in bash.rs integration tests - 13 fixes in other integration test files - 4 fixes in build.rs and cmd modules All 96 tests pass and nudge check reports zero violations. --- .nudge.yaml | 80 +++++++++++++------- packages/nudge/build.rs | 12 ++- packages/nudge/src/cmd/claude/run/process.rs | 2 +- packages/nudge/src/cmd/claude/run/ui.rs | 7 +- packages/nudge/src/cmd/test.rs | 5 +- packages/nudge/src/rules.rs | 3 +- packages/nudge/src/rules/schema.rs | 76 +++++-------------- packages/nudge/src/snippet.rs | 8 +- packages/nudge/tests/it/bash.rs | 43 ++++++++--- packages/nudge/tests/it/basic.rs | 2 +- packages/nudge/tests/it/edit_tool.rs | 4 +- packages/nudge/tests/it/external.rs | 6 +- packages/nudge/tests/it/inline_imports.rs | 2 +- packages/nudge/tests/it/message_content.rs | 2 +- packages/nudge/tests/it/multiple_rules.rs | 2 +- packages/nudge/tests/it/non_rust_files.rs | 2 +- packages/nudge/tests/it/syntax_tree.rs | 6 +- packages/nudge/tests/it/user_prompt.rs | 2 +- packages/nudge/tests/it/webfetch.rs | 6 +- 19 files changed, 156 insertions(+), 114 deletions(-) diff --git a/.nudge.yaml b/.nudge.yaml index 98c6a32..9481d41 100644 --- a/.nudge.yaml +++ b/.nudge.yaml @@ -6,10 +6,8 @@ version: 1 rules: - # Catch `use` statements inside function bodies. - # Pattern: lines starting with horizontal whitespace followed by `use `. - # Note: This regex has false positives (e.g., `use` inside `mod test`). - # For precise AST-based matching, use the SyntaxTree matcher below. + # Catch `use` statements inside function/closure bodies. + # Uses tree-sitter to distinguish block-level imports from module-level imports. - name: no-inline-imports description: Move imports to the top of the file message: Move this `use` statement to the top of the file with other imports, then retry. @@ -18,33 +16,42 @@ rules: tool: Write file: "**/*.rs" content: - - kind: Regex - pattern: "(?m)^[ \\t]+use " + - kind: SyntaxTree + language: rust + query: | + (block (use_declaration) @use) - hook: PreToolUse tool: Edit file: "**/*.rs" new_content: - - kind: Regex - pattern: "(?m)^[ \\t]+use " + - kind: SyntaxTree + language: rust + query: | + (block (use_declaration) @use) # Catch left-hand side type annotations in variable declarations. - # Pattern: `let name: Type = ...` or `let mut name: Type = ...` + # Uses tree-sitter to match `let name: Type = ...` without false positives + # from pattern matching like `let Value::Object(x) = ...`. - name: no-lhs-type-annotations description: Use type inference or turbofish instead of LHS annotations - message: "Remove this type annotation. Use turbofish (`collect::>()`) or type inference instead, then retry." + message: "Use turbofish syntax (e.g., `from_str::(...)`) or type inference instead of left-hand type annotations, then retry." on: - hook: PreToolUse tool: Write file: "**/*.rs" content: - - kind: Regex - pattern: "(?m)^\\s*let\\s+(mut\\s+)?[a-zA-Z_][a-zA-Z0-9_]*\\s*:\\s*" + - kind: SyntaxTree + language: rust + query: | + (let_declaration type: (_) @type) - hook: PreToolUse tool: Edit file: "**/*.rs" new_content: - - kind: Regex - pattern: "(?m)^\\s*let\\s+(mut\\s+)?[a-zA-Z_][a-zA-Z0-9_]*\\s*:\\s*" + - kind: SyntaxTree + language: rust + query: | + (let_declaration type: (_) @type) # Catch unnecessarily fully qualified paths in code (not imports). # Pattern: paths with 2+ `::` separators followed by ::, (, or < @@ -66,6 +73,8 @@ rules: pattern: "std(::[a-zA-Z_][a-zA-Z0-9_]*){2,}(::|\\(|<)" # Suggest using `pretty_assertions` for better test output. + # Uses tree-sitter to match the exact macro name, avoiding false positives + # from `pretty_assert_eq!` which contains `assert_eq` as a substring. - name: prefer-pretty-assertions description: Use pretty_assertions in tests for better diff output message: "Use `pretty_assert_eq!` instead. Add `use pretty_assertions::assert_eq as pretty_assert_eq;` at file top, then retry." @@ -74,35 +83,54 @@ rules: tool: Write file: "**/*.rs" content: - - kind: Regex - pattern: "assert_eq!" + - kind: SyntaxTree + language: rust + query: | + (macro_invocation + macro: (identifier) @macro + (#eq? @macro "assert_eq")) - hook: PreToolUse tool: Edit file: "**/*.rs" new_content: - - kind: Regex - pattern: "assert_eq!" + - kind: SyntaxTree + language: rust + query: | + (macro_invocation + macro: (identifier) @macro + (#eq? @macro "assert_eq")) # Catch .unwrap() calls and suggest .expect() instead. - # Demonstrates the suggestion feature with named capture groups. + # Uses tree-sitter to match actual method calls, avoiding false positives + # from doc comments or string literals containing ".unwrap()". - name: no-unwrap description: Use .expect() instead of .unwrap() for better error messages - message: "{{ $suggestion }}" + message: 'Use `.expect("descriptive error message")` instead of `.unwrap()`, then retry.' on: - hook: PreToolUse tool: Write file: "**/*.rs" content: - - kind: Regex - pattern: "(?P[a-zA-Z_][a-zA-Z0-9_]*)\\.unwrap\\(\\)" - suggestion: 'Replace `{{ $expr }}.unwrap()` with `{{ $expr }}.expect("descriptive error message")`' + - kind: SyntaxTree + language: rust + query: | + (call_expression + function: (field_expression + field: (field_identifier) @method + (#eq? @method "unwrap")) + arguments: (arguments)) - hook: PreToolUse tool: Edit file: "**/*.rs" new_content: - - kind: Regex - pattern: "(?P[a-zA-Z_][a-zA-Z0-9_]*)\\.unwrap\\(\\)" - suggestion: 'Replace `{{ $expr }}.unwrap()` with `{{ $expr }}.expect("descriptive error message")`' + - kind: SyntaxTree + language: rust + query: | + (call_expression + function: (field_expression + field: (field_identifier) @method + (#eq? @method "unwrap")) + arguments: (arguments)) # Redirect docs.rs fetches to local cargo registry. # Demonstrates the WebFetch matcher with URL capture groups. diff --git a/packages/nudge/build.rs b/packages/nudge/build.rs index 11d42a4..3abd6df 100644 --- a/packages/nudge/build.rs +++ b/packages/nudge/build.rs @@ -147,9 +147,15 @@ impl FromStr for StatusEntry { } let mut chars = line.chars(); - let index_char = chars.next().unwrap(); - let worktree_char = chars.next().unwrap(); - let space = chars.next().unwrap(); + let index_char = chars + .next() + .expect("git status line should have index char"); + let worktree_char = chars + .next() + .expect("git status line should have worktree char"); + let space = chars + .next() + .expect("git status line should have space separator"); if space != ' ' { return Err("expected space after status".into()); diff --git a/packages/nudge/src/cmd/claude/run/process.rs b/packages/nudge/src/cmd/claude/run/process.rs index b411894..aec41db 100644 --- a/packages/nudge/src/cmd/claude/run/process.rs +++ b/packages/nudge/src/cmd/claude/run/process.rs @@ -157,7 +157,7 @@ impl ClaudeProcess { trace!(%line, "Received message from Claude"); - let msg: OutputMessage = serde_json::from_str(line) + let msg = serde_json::from_str::(line) .wrap_err_with(|| format!("Failed to parse message: {}", line))?; // Track session ID diff --git a/packages/nudge/src/cmd/claude/run/ui.rs b/packages/nudge/src/cmd/claude/run/ui.rs index 316ea56..cbe479d 100644 --- a/packages/nudge/src/cmd/claude/run/ui.rs +++ b/packages/nudge/src/cmd/claude/run/ui.rs @@ -52,11 +52,11 @@ impl TerminalUI { Some(path.to_string()) } "Write" => { - let parsed: PreToolUseWriteInput = serde_json::from_value(input.clone()).ok()?; + let parsed = serde_json::from_value::(input.clone()).ok()?; Some(parsed.file_path.display().to_string()) } "Edit" => { - let parsed: PreToolUseEditInput = serde_json::from_value(input.clone()).ok()?; + let parsed = serde_json::from_value::(input.clone()).ok()?; Some(parsed.file_path.display().to_string()) } "Bash" => { @@ -78,7 +78,8 @@ impl TerminalUI { Some(format!("/{}/", pattern)) } "WebFetch" => { - let parsed: PreToolUseWebFetchInput = serde_json::from_value(input.clone()).ok()?; + let parsed = + serde_json::from_value::(input.clone()).ok()?; Some(parsed.url) } "WebSearch" => { diff --git a/packages/nudge/src/cmd/test.rs b/packages/nudge/src/cmd/test.rs index 97164aa..303e41a 100644 --- a/packages/nudge/src/cmd/test.rs +++ b/packages/nudge/src/cmd/test.rs @@ -151,7 +151,10 @@ fn build_hook(config: &Config) -> Result { } fn build_user_prompt_hook(config: &Config) -> Result { - let prompt = config.prompt.as_ref().unwrap(); + let prompt = config + .prompt + .as_ref() + .expect("prompt required for UserPromptSubmit hook"); let payload = json!({ "hook_event_name": "UserPromptSubmit", diff --git a/packages/nudge/src/rules.rs b/packages/nudge/src/rules.rs index 28d3a11..04f8c3c 100644 --- a/packages/nudge/src/rules.rs +++ b/packages/nudge/src/rules.rs @@ -112,7 +112,8 @@ mod tests { #[test] fn test_load_nonexistent_file() { - let rules = load_from(Path::new("nonexistent.yaml")).unwrap(); + let rules = + load_from(Path::new("nonexistent.yaml")).expect("load returns empty for nonexistent"); assert!(rules.is_empty()); } } diff --git a/packages/nudge/src/rules/schema.rs b/packages/nudge/src/rules/schema.rs index d187e60..99fd03c 100644 --- a/packages/nudge/src/rules/schema.rs +++ b/packages/nudge/src/rules/schema.rs @@ -1227,7 +1227,7 @@ mod tests { language: rust query: "(function_item)" "#; - let matcher: ContentMatcher = serde_yaml::from_str(yaml).unwrap(); + let matcher = serde_yaml::from_str::(yaml).expect("valid yaml"); assert!(matches!(matcher, ContentMatcher::SyntaxTree { .. })); } @@ -1238,7 +1238,7 @@ mod tests { language: rust query: "(not_a_real_node)" "#; - let result: Result = serde_yaml::from_str(yaml); + let result = serde_yaml::from_str::(yaml); assert!(result.is_err()); } @@ -1246,7 +1246,7 @@ mod tests { fn test_syntax_tree_is_match() { let matcher = ContentMatcher::SyntaxTree { language: Language::Rust, - query: TreeSitterQuery::new(Language::Rust, "(function_item)").unwrap(), + query: TreeSitterQuery::new(Language::Rust, "(function_item)").expect("valid query"), suggestion: None, }; assert!(matcher.is_match("fn foo() {}")); @@ -1257,7 +1257,8 @@ mod tests { fn test_syntax_tree_matches_returns_spans() { let matcher = ContentMatcher::SyntaxTree { language: Language::Rust, - query: TreeSitterQuery::new(Language::Rust, "(function_item) @fn").unwrap(), + query: TreeSitterQuery::new(Language::Rust, "(function_item) @fn") + .expect("valid tree-sitter query"), suggestion: None, }; let code = "fn foo() {}\nfn bar() {}"; @@ -1267,54 +1268,13 @@ mod tests { #[test] fn test_syntax_tree_captures_as_source_text() { - let matcher = ContentMatcher::SyntaxTree { - language: Language::Rust, - query: TreeSitterQuery::new( - Language::Rust, - "(function_item name: (identifier) @fn_name)", - ) - .unwrap(), - suggestion: None, - }; - let code = "fn my_function() {}"; - let matches = matcher.matches_with_context(code); - pretty_assert_eq!(matches.len(), 1); - pretty_assert_eq!( - matches[0].captures.get("fn_name"), - Some(&"my_function".to_string()) - ); - } - - #[test] - fn test_syntax_tree_suggestion_interpolation() { - let matcher = ContentMatcher::SyntaxTree { - language: Language::Rust, - query: TreeSitterQuery::new( - Language::Rust, - "(function_item name: (identifier) @fn_name)", - ) - .unwrap(), - suggestion: Some("Rename {{ $fn_name }} to something descriptive".to_string()), - }; - let code = "fn x() {}"; - let matches = matcher.matches_with_context(code); - pretty_assert_eq!(matches.len(), 1); - pretty_assert_eq!( - matches[0].captures.get("suggestion"), - Some(&"Rename x to something descriptive".to_string()) - ); - } - - #[test] - fn test_syntax_tree_use_in_function_body() { - // This is the motivating use case: match `use` inside function bodies let matcher = ContentMatcher::SyntaxTree { language: Language::Rust, query: TreeSitterQuery::new( Language::Rust, "(function_item body: (block (use_declaration) @use))", ) - .unwrap(), + .expect("valid tree-sitter query"), suggestion: None, }; @@ -1332,7 +1292,8 @@ mod tests { // Malformed code should not match (passes silently) let matcher = ContentMatcher::SyntaxTree { language: Language::Rust, - query: TreeSitterQuery::new(Language::Rust, "(function_item)").unwrap(), + query: TreeSitterQuery::new(Language::Rust, "(function_item)") + .expect("valid tree-sitter query"), suggestion: None, }; // Completely malformed - no function keyword @@ -1350,7 +1311,7 @@ mod tests { Language::Rust, "(function_item name: (identifier) @name body: (block) @body)", ) - .unwrap(), + .expect("valid tree-sitter query"), suggestion: None, }; let code = "fn foo() { let x = 1; }"; @@ -1368,7 +1329,8 @@ mod tests { kind: External command: ["grep", "-q", "error"] "#; - let matcher: ContentMatcher = serde_yaml::from_str(yaml).unwrap(); + let matcher = + serde_yaml::from_str::(yaml).expect("valid external matcher yaml"); assert!(matches!(matcher, ContentMatcher::External { .. })); } @@ -1378,7 +1340,7 @@ mod tests { kind: External command: [] "#; - let result: Result = serde_yaml::from_str(yaml); + let result = serde_yaml::from_str::(yaml); assert!(result.is_err()); } @@ -1456,7 +1418,8 @@ mod tests { - kind: Regex pattern: "git\\s+push" "#; - let matcher: PreToolUseBashMatcher = serde_yaml::from_str(yaml).unwrap(); + let matcher = + serde_yaml::from_str::(yaml).expect("valid bash matcher yaml"); pretty_assert_eq!(matcher.command.len(), 1); assert!(matcher.project_state.is_empty()); } @@ -1475,7 +1438,8 @@ mod tests { - kind: Regex pattern: "^main$" "#; - let matcher: PreToolUseBashMatcher = serde_yaml::from_str(yaml).unwrap(); + let matcher = + serde_yaml::from_str::(yaml).expect("valid bash matcher yaml"); pretty_assert_eq!(matcher.command.len(), 1); pretty_assert_eq!(matcher.project_state.len(), 1); } @@ -1488,7 +1452,8 @@ mod tests { - kind: Regex pattern: "^main$" "#; - let matcher: ProjectStateMatcher = serde_yaml::from_str(yaml).unwrap(); + let matcher = serde_yaml::from_str::(yaml) + .expect("valid project state matcher yaml"); assert!(matches!(matcher, ProjectStateMatcher::Git { .. })); } @@ -1498,7 +1463,8 @@ mod tests { kind: Git branch: [] "#; - let matcher: ProjectStateMatcher = serde_yaml::from_str(yaml).unwrap(); + let matcher = serde_yaml::from_str::(yaml) + .expect("valid project state matcher yaml"); let ProjectStateMatcher::Git { branch } = matcher; assert!(branch.is_empty()); } @@ -1508,7 +1474,7 @@ mod tests { let yaml = r#" kind: InvalidKind "#; - let result: Result = serde_yaml::from_str(yaml); + let result = serde_yaml::from_str::(yaml); assert!(result.is_err()); } } diff --git a/packages/nudge/src/snippet.rs b/packages/nudge/src/snippet.rs index f48355f..5688d5d 100644 --- a/packages/nudge/src/snippet.rs +++ b/packages/nudge/src/snippet.rs @@ -183,9 +183,13 @@ mod tests { #[test] fn test_render_snippet_multiple_spans() { let source = "fn main() {\n use std::io;\n use std::fs;\n}"; - let use1_start = source.find("use std::io").unwrap(); + let use1_start = source + .find("use std::io") + .expect("source contains use std::io"); let use1_end = use1_start + "use std::io".len(); - let use2_start = source.find("use std::fs").unwrap(); + let use2_start = source + .find("use std::fs") + .expect("source contains use std::fs"); let use2_end = use2_start + "use std::fs".len(); let result = super::Source::from(source).annotate([ diff --git a/packages/nudge/tests/it/bash.rs b/packages/nudge/tests/it/bash.rs index 8f793ef..abd0c1a 100644 --- a/packages/nudge/tests/it/bash.rs +++ b/packages/nudge/tests/it/bash.rs @@ -74,7 +74,11 @@ fn get_binary_path() -> PathBuf { assert!(status.success(), "cargo build failed"); let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir.parent().unwrap().parent().unwrap(); + let workspace_root = manifest_dir + .parent() + .expect("manifest dir has parent") + .parent() + .expect("parent has parent"); workspace_root.join("target/debug/nudge") } @@ -150,7 +154,10 @@ rules: let dir = setup_config(config); // Should match: rm -rf command - let input = bash_hook("rm -rf /some/path", dir.path().to_str().unwrap()); + let input = bash_hook( + "rm -rf /some/path", + dir.path().to_str().expect("temp dir path is valid utf-8"), + ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); assert!( @@ -178,7 +185,10 @@ rules: let dir = setup_config(config); // Should not match: safe rm command - let input = bash_hook("rm file.txt", dir.path().to_str().unwrap()); + let input = bash_hook( + "rm file.txt", + dir.path().to_str().expect("temp dir path is valid utf-8"), + ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); assert!( @@ -187,7 +197,10 @@ rules: ); // Should not match: unrelated command - let input = bash_hook("ls -la", dir.path().to_str().unwrap()); + let input = bash_hook( + "ls -la", + dir.path().to_str().expect("temp dir path is valid utf-8"), + ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); assert!( @@ -221,7 +234,10 @@ rules: let dir = setup_git_repo("main", config); // Should match: git push on main branch - let input = bash_hook("git push origin main", dir.path().to_str().unwrap()); + let input = bash_hook( + "git push origin main", + dir.path().to_str().expect("temp dir path is valid utf-8"), + ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); assert!( @@ -261,7 +277,7 @@ rules: // Should NOT match: git push on feature branch (not main) let input = bash_hook( "git push origin feature-branch", - dir.path().to_str().unwrap(), + dir.path().to_str().expect("temp dir path is valid utf-8"), ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); @@ -296,7 +312,10 @@ rules: let dir = setup_config(config); // Should NOT match: not a git repo, so project_state fails (with warning) - let input = bash_hook("git push origin main", dir.path().to_str().unwrap()); + let input = bash_hook( + "git push origin main", + dir.path().to_str().expect("temp dir path is valid utf-8"), + ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); assert!( @@ -324,7 +343,10 @@ rules: let dir = setup_config(config); // Should match: git push without project_state requirement - let input = bash_hook("git push origin main", dir.path().to_str().unwrap()); + let input = bash_hook( + "git push origin main", + dir.path().to_str().expect("temp dir path is valid utf-8"), + ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); assert!( @@ -358,7 +380,10 @@ rules: let dir = setup_git_repo("main", config); // Should match: git push on main branch - let input = bash_hook("git push", dir.path().to_str().unwrap()); + let input = bash_hook( + "git push", + dir.path().to_str().expect("temp dir path is valid utf-8"), + ); let (exit_code, output) = run_hook_in_dir(&dir, &input); pretty_assert_eq!(exit_code, 0, "expected exit 0, output: {output}"); assert!( diff --git a/packages/nudge/tests/it/basic.rs b/packages/nudge/tests/it/basic.rs index 02c46c8..acdc552 100644 --- a/packages/nudge/tests/it/basic.rs +++ b/packages/nudge/tests/it/basic.rs @@ -6,7 +6,7 @@ use xshell::Shell; #[test] fn test_no_rules_passthrough() { // Non-matching file extension should passthrough (no rules match) - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); let input = write_hook("test.xyz", "any content"); let (exit_code, output) = run_hook(&sh, &input); assert_expected(exit_code, &output, Expected::Passthrough); diff --git a/packages/nudge/tests/it/edit_tool.rs b/packages/nudge/tests/it/edit_tool.rs index 17477bd..d9ebc92 100644 --- a/packages/nudge/tests/it/edit_tool.rs +++ b/packages/nudge/tests/it/edit_tool.rs @@ -5,7 +5,7 @@ use xshell::Shell; #[test] fn test_edit_tool_content_matching() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); // Edit that introduces an indented use statement let input = edit_hook("test.rs", "old code", " use std::io;\n"); let (exit_code, output) = run_hook(&sh, &input); @@ -14,7 +14,7 @@ fn test_edit_tool_content_matching() { #[test] fn test_edit_tool_non_matching() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); // Edit that doesn't trigger any rules let input = edit_hook("test.rs", "old", "new"); let (exit_code, output) = run_hook(&sh, &input); diff --git a/packages/nudge/tests/it/external.rs b/packages/nudge/tests/it/external.rs index e5c716c..dee4cf6 100644 --- a/packages/nudge/tests/it/external.rs +++ b/packages/nudge/tests/it/external.rs @@ -30,7 +30,11 @@ fn get_binary_path() -> PathBuf { assert!(status.success(), "cargo build failed"); let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir.parent().unwrap().parent().unwrap(); + let workspace_root = manifest_dir + .parent() + .expect("manifest dir has parent") + .parent() + .expect("parent has parent"); workspace_root.join("target/debug/nudge") } diff --git a/packages/nudge/tests/it/inline_imports.rs b/packages/nudge/tests/it/inline_imports.rs index a68e805..1609731 100644 --- a/packages/nudge/tests/it/inline_imports.rs +++ b/packages/nudge/tests/it/inline_imports.rs @@ -16,7 +16,7 @@ use xshell::Shell; )] #[test] fn test_inline_imports(content: &str, expected: Expected) { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); let input = write_hook("test.rs", content); let (exit_code, output) = run_hook(&sh, &input); assert_expected(exit_code, &output, expected); diff --git a/packages/nudge/tests/it/message_content.rs b/packages/nudge/tests/it/message_content.rs index e7ac03c..0bdf468 100644 --- a/packages/nudge/tests/it/message_content.rs +++ b/packages/nudge/tests/it/message_content.rs @@ -5,7 +5,7 @@ use xshell::Shell; #[test] fn test_interrupt_message_contains_rule_message() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); let input = write_hook("test.rs", "fn main() {\n use std::io;\n}"); let (_, output) = run_hook(&sh, &input); diff --git a/packages/nudge/tests/it/multiple_rules.rs b/packages/nudge/tests/it/multiple_rules.rs index 029c0ab..f5640a5 100644 --- a/packages/nudge/tests/it/multiple_rules.rs +++ b/packages/nudge/tests/it/multiple_rules.rs @@ -6,7 +6,7 @@ use xshell::Shell; #[test] fn test_multiple_rules_fire() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); // Content that triggers both inline imports AND lhs type annotations let content = "fn main() {\n use std::io;\n let foo: Vec = vec![];\n}"; let input = write_hook("test.rs", content); diff --git a/packages/nudge/tests/it/non_rust_files.rs b/packages/nudge/tests/it/non_rust_files.rs index d76068b..0e42637 100644 --- a/packages/nudge/tests/it/non_rust_files.rs +++ b/packages/nudge/tests/it/non_rust_files.rs @@ -9,7 +9,7 @@ use xshell::Shell; #[test_case("test.txt", "let foo: Type = bar;"; "text file passes")] #[test] fn test_non_rust_files_pass(file_path: &str, content: &str) { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); let input = write_hook(file_path, content); let (exit_code, output) = run_hook(&sh, &input); assert_expected(exit_code, &output, Expected::Passthrough); diff --git a/packages/nudge/tests/it/syntax_tree.rs b/packages/nudge/tests/it/syntax_tree.rs index 324de9e..4757fa6 100644 --- a/packages/nudge/tests/it/syntax_tree.rs +++ b/packages/nudge/tests/it/syntax_tree.rs @@ -42,7 +42,11 @@ fn get_binary_path() -> PathBuf { // Get the target directory - use CARGO_TARGET_DIR or default let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir.parent().unwrap().parent().unwrap(); + let workspace_root = manifest_dir + .parent() + .expect("manifest dir has parent") + .parent() + .expect("parent has parent"); workspace_root.join("target/debug/nudge") } diff --git a/packages/nudge/tests/it/user_prompt.rs b/packages/nudge/tests/it/user_prompt.rs index 308747e..41f1b84 100644 --- a/packages/nudge/tests/it/user_prompt.rs +++ b/packages/nudge/tests/it/user_prompt.rs @@ -5,7 +5,7 @@ use xshell::Shell; #[test] fn test_user_prompt_no_matching_rules() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); let input = user_prompt_hook("hello world"); let (exit_code, output) = run_hook(&sh, &input); // No UserPromptSubmit rules in the test config, so should passthrough diff --git a/packages/nudge/tests/it/webfetch.rs b/packages/nudge/tests/it/webfetch.rs index aeea1d7..ba695b5 100644 --- a/packages/nudge/tests/it/webfetch.rs +++ b/packages/nudge/tests/it/webfetch.rs @@ -5,7 +5,7 @@ use xshell::Shell; #[test] fn test_webfetch_docs_rs_triggers_interrupt() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); // WebFetch to docs.rs should trigger the rule let input = webfetch_hook( "https://docs.rs/serde/1.0.0/serde/", @@ -17,7 +17,7 @@ fn test_webfetch_docs_rs_triggers_interrupt() { #[test] fn test_webfetch_other_url_passes() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); // WebFetch to a non-matched URL should pass through let input = webfetch_hook("https://example.com/page", "What is this?"); let (exit_code, output) = run_hook(&sh, &input); @@ -26,7 +26,7 @@ fn test_webfetch_other_url_passes() { #[test] fn test_webfetch_captures_crate_name() { - let sh = Shell::new().unwrap(); + let sh = Shell::new().expect("create shell"); // WebFetch to docs.rs should capture the crate name in the message let input = webfetch_hook("https://docs.rs/tokio/1.0.0/tokio/", "Tell me about tokio"); let (exit_code, output) = run_hook(&sh, &input); From f7c320c3d435f905cbebfefc0a8976e825583ec7 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Thu, 22 Jan 2026 17:11:30 -0800 Subject: [PATCH 4/5] Document 'nudge check' command in README.md and CLAUDE.md --- CLAUDE.md | 3 +++ README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1faca3e..53702f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ cargo run -p nudge -- claude setup # Install hooks into .claude/settings.loc cargo run -p nudge -- claude docs # Print rule writing documentation cargo run -p nudge -- test # Test a rule against sample input cargo run -p nudge -- validate # Validate rule config files +cargo run -p nudge -- check # Check project files against rules (for CI) ``` ## Architecture @@ -49,6 +50,7 @@ nudge claude setup - Writes hook configuration to .claude/settings.local.json nudge claude docs - Prints documentation for writing rules nudge test - Test a specific rule against sample input nudge validate - Validate and display parsed rule configs +nudge check - Check project files against rules (CI/linter mode) ``` ### Module Layout @@ -59,6 +61,7 @@ nudge validate - Validate and display parsed rule configs - `src/cmd/claude/docs.rs` - Docs command: prints rule writing guide - `src/cmd/test.rs` - Test command: test a rule against sample input - `src/cmd/validate.rs` - Validate command: parse and display rule configs +- `src/cmd/check.rs` - Check command: validate project files against rules for CI - `src/rules.rs` - Rule loading from config files - `src/rules/schema.rs` - Rule schema types and matchers (serde types that double as evaluators) - `src/claude/hook.rs` - Hook payload and response types diff --git a/README.md b/README.md index 0face02..2b63c7f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,46 @@ claude --debug You'll see Nudge's hook being called and its response in the logs. +### CI / Linting Mode + +Use `nudge check` to validate your entire project against rules—useful for CI pipelines or local linting: + +```bash +# Check entire project +nudge check + +# Check specific paths or patterns +nudge check src/ +nudge check "**/*.rs" + +# Use in CI (fails build on violations) +nudge check || exit 1 +``` + +Example output when violations are found: + +``` +✗ Found 3 issues in 2 files + +./src/main.rs:42 [no-unwrap] + Use `.expect("descriptive error message")` instead of `.unwrap()`, then retry. + +./src/lib.rs:15 [no-inline-imports] + Move this `use` statement to the top of the file, then retry. + +./src/lib.rs:23 [no-inline-imports] + Move this `use` statement to the top of the file, then retry. + +Checked 25 files against 6 rules +``` + +When all checks pass: + +``` +✓ Checked 25 files against 6 rules + - .nudge.yaml: 6 rules +``` + ### Manual Testing You can test a specific rule with the `test` subcommand: From e5881a2a062192b987970ffde8c849095645f766 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Thu, 22 Jan 2026 17:12:29 -0800 Subject: [PATCH 5/5] Fix formatting (wrap long comment) --- packages/nudge/src/cmd/check.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nudge/src/cmd/check.rs b/packages/nudge/src/cmd/check.rs index 887a856..e8acfc5 100644 --- a/packages/nudge/src/cmd/check.rs +++ b/packages/nudge/src/cmd/check.rs @@ -17,7 +17,8 @@ use nudge::rules::{self, GlobMatcher, Hook, PreToolUseMatcher, Rule}; #[derive(Args, Clone, Debug)] pub struct Config { - /// Paths or glob patterns to check. If not specified, checks entire project. + /// Paths or glob patterns to check. If not specified, checks entire + /// project. #[arg()] pub paths: Vec, }