Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 54 additions & 26 deletions .nudge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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::<Vec<_>>()`) or type inference instead, then retry."
message: "Use turbofish syntax (e.g., `from_str::<Type>(...)`) 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 <
Expand All @@ -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."
Expand All @@ -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<expr>[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<expr>[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.
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 15 additions & 12 deletions packages/benchmark/src/agent.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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
Expand All @@ -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})"),
}
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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:?}"))
}
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -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<Self, Self::Err> {
Expand All @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/benchmark/src/cmd/syntax.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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()
}
Expand Down
Loading