From 5c04c44926e2d44c76720f6a76b968e8d1d2fe0d Mon Sep 17 00:00:00 2001 From: Kasper Junge Date: Thu, 1 Oct 2026 23:32:29 +0200 Subject: [PATCH 1/2] feat: respect ignore files and support path exclusions --- Cargo.lock | 98 +++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- README.md | 19 ++++++- src/lib.rs | 34 ++++++++++-- src/main.rs | 12 ++++- tests/cli.rs | 133 +++++++++++++++++++++++++++++++++++++++++++++++ website/usage.md | 19 ++++++- 7 files changed, 309 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9376e6f..2d237d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "annotate-snippets" version = "0.12.16" @@ -69,6 +78,16 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -121,6 +140,31 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "equivalent" version = "1.0.2" @@ -163,6 +207,19 @@ dependencies = [ "r-efi", ] +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -175,6 +232,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -209,6 +282,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -276,6 +355,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustix" version = "1.1.5" @@ -295,11 +391,11 @@ version = "0.2.2" dependencies = [ "annotate-snippets", "clap", + "ignore", "percent-encoding", "pulldown-cmark", "serde_yaml_ng", "tempfile", - "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a05e898..c507d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ description = "A harness linter, written in Rust" [dependencies] clap = { version = "4", features = ["derive"] } serde_yaml_ng = "0.10" -walkdir = "2" +ignore = "0.4" pulldown-cmark = "0.13" percent-encoding = "2" annotate-snippets = "0.12.16" diff --git a/README.md b/README.md index 865a0fb..b34043a 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,24 @@ ryni check ./skills/review # A single skill directory No configuration is needed. Ryni recursively checks `.md` and `.markdown` files (case-insensitive extensions) for broken local links. Files named exactly `SKILL.md` also receive all five Agent Skills metadata checks. Hidden directories such as `.agents/skills` are -included. Nested symlinks are skipped. No ignore-file filtering is applied. +included unless ignored. Nested symlinks are skipped. + +Ryni respects `.gitignore`, `.ignore`, `.git/info/exclude`, and global Git +excludes, including applicable parent and nested ignore files. Git ignore rules +apply inside Git repositories; `.ignore` also works outside them. Matching files +are skipped even if tracked by Git. Global excludes can make scan coverage differ +between your machine and CI. + +```sh +ryni check . --no-ignore # Disable ignore-file filtering +ryni check . --exclude 'vendor/**' --exclude 'third_party/**' +``` + +`--exclude` accepts repeatable gitignore-style globs relative to the scan root +and still applies with `--no-ignore`. Patterns containing a slash are relative +to that root; patterns such as `*.md` match at any depth. Quote globs to prevent +shell expansion. Filtering only controls which files are scanned: links to +existing targets inside excluded directories remain valid. Example output: diff --git a/src/lib.rs b/src/lib.rs index 338a941..90c65ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,12 @@ mod rules; +use ignore::{WalkBuilder, overrides::OverrideBuilder}; use std::{ fs, ops::Range, path::{Path, PathBuf}, sync::Arc, }; -use walkdir::WalkDir; #[derive(Debug, PartialEq, Eq)] pub struct Diagnostic { @@ -25,18 +25,46 @@ pub struct CheckReport { pub diagnostics: Vec, } +#[derive(Debug, Default)] +pub struct CheckOptions { + pub no_ignore: bool, + pub exclude: Vec, +} + /// Discover supported files and run all applicable built-in rules. /// I/O errors are separate from lint violations; an empty scan is explicit. pub fn check(project: &Path) -> Result { + check_with_options(project, &CheckOptions::default()) +} + +pub fn check_with_options(project: &Path, options: &CheckOptions) -> Result { let project = fs::canonicalize(project) .map_err(|error| format!("cannot open project {}: {error}", project.display()))?; if !project.is_dir() { return Err(format!("expected a directory: {}", project.display())); } + let mut exclusions = OverrideBuilder::new(&project); + for pattern in &options.exclude { + exclusions + .add(&format!("!{pattern}")) + .map_err(|error| format!("invalid exclusion {pattern:?}: {error}"))?; + } + let mut walker = WalkBuilder::new(&project); + walker + .standard_filters(!options.no_ignore) + .hidden(false) + .follow_links(false) + .overrides(exclusions.build().map_err(|error| error.to_string())?); let mut paths = Vec::new(); - for entry in WalkDir::new(&project).follow_links(false) { + for entry in walker.build() { let entry = entry.map_err(|error| format!("cannot discover Markdown files: {error}"))?; - if entry.file_type().is_file() + if let Some(error) = entry.error() { + return Err(format!( + "cannot read ignore rules for {}: {error}", + entry.path().display() + )); + } + if entry.file_type().is_some_and(|kind| kind.is_file()) && entry.path().extension().is_some_and(|extension| { extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") }) diff --git a/src/main.rs b/src/main.rs index 4a63d5b..6152b82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,12 +17,22 @@ enum Command { /// Directory to scan recursively. #[arg(default_value = ".")] project: PathBuf, + /// Disable ignore-file filtering (explicit exclusions still apply). + #[arg(long)] + no_ignore: bool, + /// Exclude a gitignore-style glob relative to the scan root; repeatable. + #[arg(long, value_name = "GLOB")] + exclude: Vec, }, } fn main() -> ExitCode { match Cli::parse().command { - Command::Check { project } => match ryni::check(&project) { + Command::Check { + project, + no_ignore, + exclude, + } => match ryni::check_with_options(&project, &ryni::CheckOptions { no_ignore, exclude }) { Ok(report) if report.files_checked == 0 => { println!("No supported files found."); ExitCode::SUCCESS diff --git a/tests/cli.rs b/tests/cli.rs index 724a222..b95ac97 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -239,3 +239,136 @@ fn multiline_and_unicode_links_render_without_panicking() { assert!(stdout.contains("continued](missing.md)"), "{stdout}"); assert!(stdout.contains('^'), "{stdout}"); } + +fn run_with_args(path: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ryni")) + .arg("check") + .arg(path) + .args(args) + .output() + .unwrap() +} + +fn write_file(root: &Path, name: &str, content: &str) { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); +} + +#[test] +fn ignore_files_and_explicit_exclusions_control_scan_scope() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + // A repository marker enables Git ignore rules without invoking Git. + fs::create_dir(root.join(".git")).unwrap(); + write_file(root, ".gitignore", "node_modules/\n.agents/ignored/\n"); + write_file(root, ".ignore", "generated/\n"); + write_file(root, ".git/info/exclude", "local/\n"); + write_file(root, "docs/.gitignore", "skip.md\n"); + for path in [ + "node_modules/vendor/README.md", + "generated/README.md", + "local/README.md", + "docs/skip.md", + ".agents/ignored/SKILL.md", + "vendor/README.md", + "third_party/README.md", + ] { + write_file(root, path, "[Broken](missing.txt)\n"); + } + write_file(root, ".claude/skills/demo-skill/SKILL.md", VALID); + write_file( + root, + "README.md", + "[Dependency](node_modules/vendor/README.md)\n[Vendor](vendor/README.md)\n", + ); + let output = run(root); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(stdout.ends_with("Found 2 errors.\n"), "{stdout}"); + assert_eq!( + run_with_args( + root, + &["--exclude", "vendor/**", "--exclude", "third_party/**"] + ) + .status + .code(), + Some(0) + ); + let output = run_with_args( + root, + &[ + "--no-ignore", + "--exclude", + "vendor/**", + "--exclude", + "third_party/**", + "--exclude", + ".agents/**", + ], + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(stdout.ends_with("Found 4 errors.\n"), "{stdout}"); +} + +#[test] +fn ignore_file_works_without_git_and_preserves_negations() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + write_file(root, ".ignore", "*.md\n!keep.md\n"); + write_file(root, "skip.md", "[Broken](missing.txt)"); + write_file(root, "keep.md", "[Broken](missing.txt)"); + let output = run(root); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(stdout.contains("keep.md:1:1"), "{stdout}"); + assert!(stdout.ends_with("Found 1 error.\n"), "{stdout}"); + let output = run_with_args(root, &["--no-ignore"]); + assert!( + String::from_utf8(output.stdout) + .unwrap() + .ends_with("Found 2 errors.\n") + ); +} + +#[test] +fn invalid_exclusion_is_an_execution_error() { + let project = project(); + let output = run_with_args(project.path(), &["--exclude", "["]); + assert_eq!(output.status.code(), Some(2)); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("invalid exclusion") + ); +} + +#[test] +fn global_git_excludes_can_be_disabled() { + let project = tempfile::tempdir().unwrap(); + let config = tempfile::tempdir().unwrap(); + fs::create_dir(project.path().join(".git")).unwrap(); + write_file(project.path(), "global-only.md", "[Broken](missing.txt)"); + let excludes = config.path().join("excludes"); + fs::write(&excludes, "global-only.md\n").unwrap(); + let config_path = config.path().join("config"); + fs::write( + &config_path, + format!( + "[core]\nexcludesFile = {}\n", + excludes.to_string_lossy().replace('\\', "/") + ), + ) + .unwrap(); + for (args, expected) in [(vec![], 0), (vec!["--no-ignore"], 1)] { + let output = Command::new(env!("CARGO_BIN_EXE_ryni")) + .arg("check") + .arg(project.path()) + .args(args) + .env("GIT_CONFIG_GLOBAL", &config_path) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(expected), "{output:?}"); + } +} diff --git a/website/usage.md b/website/usage.md index 94fb6fa..6e303e5 100644 --- a/website/usage.md +++ b/website/usage.md @@ -9,7 +9,24 @@ ryni check ./skills/review # A single skill directory No configuration is needed. Ryni recursively checks `.md` and `.markdown` files (case-insensitive extensions) for broken local links. Files named exactly `SKILL.md` also receive all five Agent Skills metadata checks. Hidden directories such as `.agents/skills` are -included. Nested symlinks are skipped. No ignore-file filtering is applied. +included unless ignored. Nested symlinks are skipped. + +Ryni respects `.gitignore`, `.ignore`, `.git/info/exclude`, and global Git +excludes, including applicable parent and nested ignore files. Git ignore rules +apply inside Git repositories; `.ignore` also works outside them. Matching files +are skipped even if tracked by Git. Global excludes can make scan coverage differ +between your machine and CI. + +```sh +ryni check . --no-ignore # Disable ignore-file filtering +ryni check . --exclude 'vendor/**' --exclude 'third_party/**' +``` + +`--exclude` accepts repeatable gitignore-style globs relative to the scan root +and still applies with `--no-ignore`. Patterns containing a slash are relative +to that root; patterns such as `*.md` match at any depth. Quote globs to prevent +shell expansion. Filtering only controls which files are scanned: links to +existing targets inside excluded directories remain valid. Example output: From 49028dafb17e570d40e60d492c57eb78ee5936c7 Mon Sep 17 00:00:00 2001 From: Kasper Junge Date: Sat, 3 Oct 2026 19:35:10 +0200 Subject: [PATCH 2/2] test: make global excludes fixture portable on Windows --- tests/cli.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/cli.rs b/tests/cli.rs index b95ac97..ab93890 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -347,7 +347,10 @@ fn invalid_exclusion_is_an_execution_error() { #[test] fn global_git_excludes_can_be_disabled() { let project = tempfile::tempdir().unwrap(); - let config = tempfile::tempdir().unwrap(); + let config = tempfile::Builder::new() + .prefix("ryni~config ") + .tempdir() + .unwrap(); fs::create_dir(project.path().join(".git")).unwrap(); write_file(project.path(), "global-only.md", "[Broken](missing.txt)"); let excludes = config.path().join("excludes"); @@ -355,10 +358,9 @@ fn global_git_excludes_can_be_disabled() { let config_path = config.path().join("config"); fs::write( &config_path, - format!( - "[core]\nexcludesFile = {}\n", - excludes.to_string_lossy().replace('\\', "/") - ), + // `ignore` expands every '~' in this value and cannot parse spaces. + // Keep Windows short temp paths (e.g. RUNNER~1) out of the value. + "[core]\nexcludesFile = excludes\n", ) .unwrap(); for (args, expected) in [(vec![], 0), (vec!["--no-ignore"], 1)] { @@ -366,6 +368,7 @@ fn global_git_excludes_can_be_disabled() { .arg("check") .arg(project.path()) .args(args) + .current_dir(config.path()) .env("GIT_CONFIG_GLOBAL", &config_path) .output() .unwrap();