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
98 changes: 97 additions & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
34 changes: 31 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -25,18 +25,46 @@ pub struct CheckReport {
pub diagnostics: Vec<Diagnostic>,
}

#[derive(Debug, Default)]
pub struct CheckOptions {
pub no_ignore: bool,
pub exclude: Vec<String>,
}

/// 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<CheckReport, String> {
check_with_options(project, &CheckOptions::default())
}

pub fn check_with_options(project: &Path, options: &CheckOptions) -> Result<CheckReport, String> {
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")
})
Expand Down
12 changes: 11 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
}

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
Expand Down
Loading
Loading