diff --git a/README.md b/README.md index 2c9cd63..cb28e23 100644 --- a/README.md +++ b/README.md @@ -442,11 +442,15 @@ walking up from the analysis root to the nearest directory containing one "roots": ["MyApp.Program.Main"], "libraryProjects": ["MyLib"], "ignore": ["Migrations/**", "Generated/", "**/*.designer.cs"], + "deadCode": { + "ignore": ["Plugins/**"] + }, "dupes": { "mode": "semantic", "minTokens": 100, "minLines": 10, - "minOccurrences": 2 + "minOccurrences": 2, + "ignore": ["**/InsurerUnionQuery.cs"] }, "health": { "maxComplexity": 15, @@ -469,7 +473,11 @@ explicit `--aggressive`, `--root`, or `--library` always wins, otherwise the config's value applies, otherwise the built-in default (`false` / no extra roots / no extra library projects). The same `ignore` list also applies to `roe dupes`, since a duplicate spans multiple files and doesn't map cleanly -onto a single-line inline suppression comment. +onto a single-line inline suppression comment. Each command's section takes +an `ignore` list of its own with the same rules, unioned with the top-level +one and applied only to that command — so accepting an intentional duplicate +through `dupes.ignore` doesn't cost the file its dead-code and health +coverage. The `dupes` block sets defaults for `roe dupes`' matching mode and thresholds, and the `health` block does the same for `roe health`'s diff --git a/_typos.toml b/_typos.toml index f2a73e9..08a1ffa 100644 --- a/_typos.toml +++ b/_typos.toml @@ -8,3 +8,5 @@ extend-exclude = ["Cargo.lock", "tests/fixtures/", "tests/snapshots/"] # `rejects_unknown_fields` in src/config.rs to prove serde rejects near-miss # keys instead of silently ignoring them. agressive = "agressive" +# Same idea for the `ignore` field, used by `rejects_unknown_dead_code_fields`. +ignor = "ignor" diff --git a/docs/commands/dead-code.mdx b/docs/commands/dead-code.mdx index 74420f9..5d95648 100644 --- a/docs/commands/dead-code.mdx +++ b/docs/commands/dead-code.mdx @@ -50,7 +50,10 @@ never flagged even though no executable in the workspace uses it: roe dead-code --library MyLib ``` -Both can also be set persistently in a [config file](/configuration). +Both can also be set persistently in a [config file](/configuration). A +config's `deadCode.ignore` glob list drops every dead-code finding in +matching files without affecting the other commands — see +[Suppressing findings](/suppressing-findings). ## What counts as an entry point diff --git a/docs/commands/dupes.mdx b/docs/commands/dupes.mdx index b9427af..cf1ab65 100644 --- a/docs/commands/dupes.mdx +++ b/docs/commands/dupes.mdx @@ -56,6 +56,16 @@ roe dupes --mode semantic path/to/solution ## Ignoring files Duplicates span multiple files, so there's no inline suppression comment for -them. Instead, use the `ignore` glob list in a -[config file](/configuration) — it applies to every command, dropping every -finding in matching files. +them. Instead, use ignore globs in a [config file](/configuration): the +top-level `ignore` list applies to every command, and the `dupes.ignore` +list adds patterns only the duplicate analysis honours — so accepting an +intentional duplicate doesn't cost the file its dead-code and health +coverage. + +```json roe.json +{ + "dupes": { + "ignore": ["**/InsurerUnionQuery.cs"] + } +} +``` diff --git a/docs/commands/health.mdx b/docs/commands/health.mdx index 0b86609..bba150c 100644 --- a/docs/commands/health.mdx +++ b/docs/commands/health.mdx @@ -115,8 +115,8 @@ or a `??` whose fallback contains a ternary is counted on its own merits. The footer's `N project(s), N file(s), N symbol(s) scanned` counts what was *eligible to be reported*, not what was parsed. Anything ruled out up front — test projects under `--exclude-tests`, files matched by the config's -[`ignore` globs](/configuration) — is subtracted, and named on a line of its -own: +[`ignore` globs](/configuration) (top-level or `health.ignore`) — is +subtracted, and named on a line of its own: ```text found 8 issues across 7 locations in 6 files — 2 project(s), 118 file(s), 1204 symbol(s) scanned in 153 ms @@ -398,5 +398,6 @@ public void ParseEverything(string input) ``` Circular dependencies span multiple files, so — like duplicates — they have -no inline comment. Use the `ignore` globs in a -[config file](/configuration) instead. +no inline comment. Use ignore globs in a [config file](/configuration) +instead — `health.ignore` scopes the suppression to health alone, so the +file keeps its dead-code and dupes coverage. diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 4f20972..cd6817e 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -27,11 +27,15 @@ error. "roots": ["MyApp.Program.Main"], "libraryProjects": ["MyLib"], "ignore": ["Migrations/**", "Generated/", "**/*.designer.cs"], + "deadCode": { + "ignore": ["Plugins/**"] + }, "dupes": { "mode": "semantic", "minTokens": 100, "minLines": 10, - "minOccurrences": 2 + "minOccurrences": 2, + "ignore": ["**/InsurerUnionQuery.cs"] }, "health": { "maxComplexity": 15, @@ -41,7 +45,8 @@ error. "maxFileLines": 750, "maxTypeMembers": 25, "excludeTests": true, - "baseline": "roe-baseline.json" + "baseline": "roe-baseline.json", + "ignore": ["**/GeneratedModels.cs"] } } ``` @@ -51,9 +56,10 @@ error. | `aggressive` | boolean | Also flag enum members and public settable auto-properties. Default for the `--aggressive` flag. | | `roots` | string[] | Fully-qualified symbol names to treat as extra entry-point roots. Default for `--root`. | | `libraryProjects` | string[] | Project names to always treat in library mode (public API counts as used). Default for `--library`. | -| `ignore` | string[] | Glob patterns; every finding in a matching file is dropped. Applies to all three commands. | -| `dupes` | object | Matching mode and thresholds for [`roe dupes`](/commands/dupes). Every key is optional. | -| `health` | object | Thresholds and the baseline path for [`roe health`](/commands/health). Every key is optional. | +| `ignore` | string[] | Glob patterns; every finding in a matching file is dropped. Applies to all three commands; each command's section can add its own. | +| `deadCode` | object | Extra `ignore` globs applied only to [`roe dead-code`](/commands/dead-code). | +| `dupes` | object | Matching mode, thresholds, and extra `ignore` globs for [`roe dupes`](/commands/dupes). Every key is optional. | +| `health` | object | Thresholds, the baseline path, and extra `ignore` globs for [`roe health`](/commands/health). Every key is optional. | Unknown fields are rejected, so a typo fails loudly instead of being silently ignored. @@ -69,6 +75,7 @@ Every field under `dupes` is optional and corresponds to a flag on | `minTokens` | number | `--min-tokens` | `50` | | `minLines` | number | `--min-lines` | `5` | | `minOccurrences` | number | `--min-occurrences` | `2` | +| `ignore` | string[] | — | none | This block is what calibrates a combined run: [`roe check`](/commands/check) takes no dupes flags of its own, so raising `minTokens` here is how a one-line @@ -89,6 +96,7 @@ Every field under `health` is optional and corresponds to a flag on | `maxTypeMembers` | number | `--max-type-members` | `20` | | `excludeTests` | boolean | `--exclude-tests` | `false` | | `baseline` | string | `--baseline` | none | +| `ignore` | string[] | — | none | Committing these is usually better than passing six flags on every CI invocation, and it keeps local runs and CI in agreement. @@ -108,9 +116,33 @@ full-backlog run. A trailing `/` matches the whole directory, so `"Generated/"` needs no `**`. Patterns containing `..` are unsupported and produce a warning. -The same `ignore` list also applies to `roe dupes` and to `roe health`'s -circular dependencies, since both span multiple files and don't map cleanly -onto a single-line [inline suppression comment](/suppressing-findings). +The top-level `ignore` list applies to all three commands — including +`roe dupes` and `roe health`'s circular dependencies, which span multiple +files and don't map cleanly onto a single-line +[inline suppression comment](/suppressing-findings). + +Each command's section takes an `ignore` list of its own with the same +rules, unioned with the top-level list and applied only to that command. +That keeps a suppression as narrow as the exception it accepts: ignoring an +intentional duplicate through `dupes.ignore` doesn't cost the file its +dead-code and health coverage. + +```yaml roe.yaml +ignore: + - "Generated/**" +deadCode: + ignore: + - "Plugins/**" +dupes: + ignore: + - "**/InsurerUnionQuery.cs" +health: + ignore: + - "**/GeneratedModels.cs" +``` + +A scoped list only ever adds patterns — it can't re-include a file the +top-level list ignores — and an empty one is a no-op. ## Precedence @@ -136,6 +168,10 @@ Dupes settings follow the same per-field order: `--min-tokens 100` overrides carries a value, so unlike `--aggressive` and `--exclude-tests` it has no on-only nuance — `--mode exact` overrides a config's `"semantic"` cleanly. +Ignore lists sit outside this order entirely: they have no CLI flag, and a +command's own `ignore` list is unioned with the top-level one rather than +replacing it. + To confirm a config-only `excludeTests` or `ignore` actually applied, read the `roe health` footer: the scanned counts narrow by whatever was excluded, and an `excluded:` line names it. See diff --git a/docs/reference/json-output.mdx b/docs/reference/json-output.mdx index 3d8e1f4..04885a7 100644 --- a/docs/reference/json-output.mdx +++ b/docs/reference/json-output.mdx @@ -216,7 +216,7 @@ including its own `version` and `root`, so anything already written against | --- | --- | | `version` | Schema version, `1`. | | `root` | The analysis root path. | -| `summary` | Scan totals: `projects`, `filesScanned`, `symbols`, one count per check (`highComplexity`, `highCognitiveComplexity`, `longMethods`, `tooManyParameters`, `largeFiles`, `largeTypes`, `circularDependencies`), and `elapsedMs`. `commitsWalked` is present only when `--hotspots` was passed. The three scan totals count what was eligible to be reported, so they narrow under `--exclude-tests` and the config's `ignore` globs. | +| `summary` | Scan totals: `projects`, `filesScanned`, `symbols`, one count per check (`highComplexity`, `highCognitiveComplexity`, `longMethods`, `tooManyParameters`, `largeFiles`, `largeTypes`, `circularDependencies`), and `elapsedMs`. `commitsWalked` is present only when `--hotspots` was passed. The three scan totals count what was eligible to be reported, so they narrow under `--exclude-tests` and the config's `ignore` globs (top-level and `health.ignore`). | | `summary.baselined` | How many findings and cycles a [baseline](/commands/health#baselines) hid — they are counted nowhere else in this document. Present only when a baseline was in force, so `0` means "the baseline is fully ratcheted" and absent means "no baseline was used". | | `summary.excluded` | What was ruled out before any check ran: `testProjects` (an array of project names, present under `--exclude-tests`) and `ignoredFiles` (a count). Omitted entirely when nothing was excluded. | | `findings[].kind` | `high-complexity`, `high-cognitive-complexity`, `long-method`, `too-many-parameters`, `large-file`, or `large-type` — the same names used by [inline suppressions](/suppressing-findings). | diff --git a/docs/suppressing-findings.mdx b/docs/suppressing-findings.mdx index cea0d7a..47fb177 100644 --- a/docs/suppressing-findings.mdx +++ b/docs/suppressing-findings.mdx @@ -78,10 +78,25 @@ To drop every finding in matching files — for `dead-code`, `dupes`, and } ``` +To keep a suppression as narrow as the exception it accepts, scope it to one +command instead: each command's section takes an `ignore` list of its own, +unioned with the top-level one and applied only to that command. Accepting +an intentional duplicate this way doesn't cost the file its dead-code and +health coverage: + +```json roe.json +{ + "dupes": { + "ignore": ["**/InsurerUnionQuery.cs"] + } +} +``` + See [Configuration](/configuration) for glob semantics and config discovery. Duplicate groups and circular dependencies span multiple files, so neither maps onto a single-line comment and neither has an inline suppression. - Ignore globs are the only way to silence them. + Ignore globs — top-level, `dupes.ignore`, or `health.ignore` — are the only + way to silence them. diff --git a/skills/roe/SKILL.md b/skills/roe/SKILL.md new file mode 100644 index 0000000..6713e27 --- /dev/null +++ b/skills/roe/SKILL.md @@ -0,0 +1,257 @@ +--- +name: roe +description: >- + Analyze C#/.NET codebases with the roe CLI: find dead code (unused types, + members, and files), duplicated code, and code-health problems (complexity, + size, coupling) using pure static analysis — no build required. Use this + skill whenever you are working in a repository containing .cs, .sln, or + .csproj files and the task involves deleting unused code, cleaning up, + refactoring, hunting copy-paste duplication, auditing code quality or + complexity, or gating a CI pipeline on code quality — even if the user + never mentions roe by name. +--- + +# Using roe + +roe is a codebase-intelligence CLI for C#. It parses source with tree-sitter +and never compiles or runs anything, so it works on codebases that don't +build. It is deliberately conservative: when in doubt, code is marked *used*. +A missed finding is cheap; a false positive is treated as a bug. That means +findings are high-confidence and generally safe to act on. + +roe only analyzes C# — it scans `.cs`, `.sln`, and `.csproj` files. Generated +code (`*.g.cs`, `*.Designer.cs`, `` headers, `obj/`) is read +for references but never flagged. + +## Check availability, install if needed + +```bash +roe --version +``` + +If missing, install one of: + +```bash +dotnet tool install --global roe # recommended; .NET SDK 8.0+ +npm install --global roe-cli # npm package is roe-cli, binary is roe +dnx roe . # one-shot, .NET SDK 10+ +npx roe-cli . # one-shot via npm +``` + +roe is not on crates.io; prebuilt binaries are also on GitHub Releases. + +## Core workflow + +Always use `--format json` — the output is a stable, versioned schema built +for machines. Human format is for terminals. + +```bash +roe --format json [PATH] # PATH = directory, .sln, or .csproj; defaults to cwd +``` + +A bare `roe` (or `roe check`) runs all three analyses and prints **one** +combined JSON document: `{version, root, deadCode, dupes, health}`. Each +nested value is exactly what the individual command would emit. + +Exit codes: + +| Code | Meaning | +| --- | --- | +| `0` | Clean — no findings. | +| `1` | Findings were reported. For the combined run: any analysis found something. | +| `2` | Error (`error: …` on stderr) — bad path, malformed config, etc. | + +Warnings appear on stderr as `warning: …` and never affect the exit code. + +After fixing findings, **re-run roe**. Deleting dead code cascades: code that +was only referenced by the deleted code becomes newly dead on the next run. +Repeat until clean, and verify with the project's own build and tests. + +## Commands + +### `roe check [PATH]` (the default) + +Runs dead-code, dupes, and health together. Takes only `-f/--format` and +`--config` — per-analysis tuning deliberately lives in the config file, not +flags: the `dupes` block calibrates the duplicate analysis and the `health` +block its thresholds. In the combined output, `dupes.mode` states the mode +that actually ran (`exact` unless the config says otherwise) and +`health.hotspots` is always empty. + +### `roe dead-code [PATH]` + +Finds unused types, members, and files. + +| Flag | Effect | +| --- | --- | +| `--aggressive` | Also flag enum members and public settable auto-properties. | +| `--root ` | Extra entry-point root (repeatable). Use for code reached in ways static analysis can't see: string-based reflection, external plugin hosts. | +| `--library ` | Treat this project's public API as used (repeatable). Use when the project is consumed outside the workspace — e.g. a DLL referenced by a Unity project. | + +Entry points are never flagged: `static Main` / top-level statements, +ASP.NET controllers and actions, test methods, anything carrying a non-inert +attribute, reflection-scan contracts (`AddHostedService()`-style), and the +public API of library-mode projects. + +### `roe dupes [PATH]` + +Finds duplicated code blocks. + +| Flag | Default | Effect | +| --- | --- | --- | +| `--mode ` | `exact` | `semantic` normalizes identifiers and numeric literals, so renamed-but-structurally-identical blocks match too. | +| `--min-tokens ` | `50` | Minimum token-run length to report. | +| `--min-lines ` | `5` | Minimum line span of the shortest occurrence. | +| `--min-occurrences ` | `2` | Minimum number of copies. | +| `--no-code` | | Hide the duplicated source in human output. | + +`--mode` and the three thresholds fall back to the config file's `dupes` +block before their built-in defaults, so a committed calibration also +applies to a flagless `roe check`. + +### `roe health [PATH]` + +Flags complexity, size, and coupling issues. Thresholds are +strictly-greater-than: a value equal to the threshold is clean. + +| Flag | Default | Flags … | +| --- | --- | --- | +| `--max-complexity ` | `10` | methods/properties above this cyclomatic complexity. | +| `--max-cognitive ` | `15` | above this cognitive complexity (nesting-weighted). | +| `--max-method-lines ` | `40` | method bodies spanning more lines than this. | +| `--max-parameters ` | `5` | methods declared with more required parameters than this. | +| `--max-file-lines ` | `750` | files longer than this. | +| `--max-type-members ` | `20` | types with more members than this. | + +Other flags: `--exclude-tests` (skip test projects — long arrange/act/assert +methods are normal there), `--sort `, `--limit ` (truncates +the human report only; JSON is never truncated), `--hotspots --top ` +(ranks complex-and-frequently-changed files from git history; informational, +never affects the exit code; requires a real git checkout, so CI needs +`fetch-depth: 0`), and `--baseline` / `--write-baseline` (see below). + +Circular dependencies between types are always reported (as `cycles` in +JSON) — there is no threshold for them. + +## Reading the JSON + +Schema `version` is `1`; changes are additive only. Keys are camelCase, +optional fields are omitted, and `file` paths are relative to `root`. + +Each report has a `summary` (scan totals) and `findings` array. Every finding +carries `kind`, `name` (fully-qualified symbol, or file path for file-level +findings), `file`, `line`, `column`. Health findings add `metric` and +`threshold` — `metric / threshold` is the severity. Dupes reports have +`groups[]`, each with `tokenCount`, `lineCount`, and `occurrences[]` +(`file`, `startLine`..`endColumn`). + +The `kind` values (identical to the inline-suppression rule names): +`unused-type`, `unused-member`, `unused-file`, `high-complexity`, +`high-cognitive-complexity`, `long-method`, `too-many-parameters`, +`large-file`, `large-type`. + +## Suppressing false positives + +Prefer fixing real findings over suppressing. When a finding genuinely is a +false positive (e.g. code invoked via string-based reflection), first +consider whether `--root`/`roots` describes the situation better — a root +documents *why* the code is alive, a suppression just silences the report. + +Inline comments work eslint-style, checked after the first `//` (so `///` +doc-comments work too): + +```csharp +// roe-ignore-next-line unused-type +internal class LegacyHelper { } + +public void DoWork() { } // roe-ignore-line long-method +``` + +The rule list is optional (a bare marker suppresses any kind) and +comma-separated (`// roe-ignore-next-line high-complexity,long-method`). +Dead-file and large-file findings are pinned at line 1, so their markers +work from anywhere in the file. + +Duplicate groups and circular dependencies span multiple files and have no +inline suppression — use config `ignore` globs for those. Prefer the scoped +form (`dupes.ignore`, `health.ignore`, `deadCode.ignore`) over the top-level +list when accepting one command's finding: it keeps the file covered by the +other analyses. + +## Config file + +roe looks for `roe.json`, `roe.yaml`, or `roe.yml`, walking up from the +analysis root (nearest wins), or takes `--config ` explicitly. Unknown +fields are a hard error, so typos fail loudly. + +```json +{ + "aggressive": false, + "roots": ["App.Jobs.NightlyCleanupJob"], + "libraryProjects": ["App.Sdk"], + "ignore": ["Migrations/**", "Generated/"], + "deadCode": { + "ignore": ["Plugins/**"] + }, + "dupes": { + "mode": "exact", + "minTokens": 50, + "minLines": 5, + "minOccurrences": 2, + "ignore": ["**/InsurerUnionQuery.cs"] + }, + "health": { + "maxComplexity": 10, + "maxCognitive": 15, + "maxMethodLines": 40, + "maxParameters": 5, + "maxFileLines": 750, + "maxTypeMembers": 20, + "excludeTests": true, + "baseline": ".roe-baseline.json", + "ignore": ["**/GeneratedModels.cs"] + } +} +``` + +`ignore` globs resolve relative to the config file's directory, and a +trailing `/` means the whole subtree (`Generated/` ≡ `Generated/**`). The +top-level list applies to all three analyses; each command section's own +`ignore` list is unioned with it and applies to that command alone — the +narrow tool for accepting one command's finding without silencing the +others. + +Precedence is CLI flag → config → built-in default, with gotchas: + +- `--aggressive` and `--exclude-tests` are OR'd with the config — a config + `true` cannot be turned off from the CLI. +- `--root` and `--library` **replace** the config's `roots`/`libraryProjects` + lists entirely rather than merging with them. +- The `dupes` settings are plain per-field overrides (`--mode exact` beats a + config `"semantic"`), and ignore lists have no CLI flag at all — scoped + lists only ever add to the top-level one. + +## Adopting roe on a legacy codebase + +Don't try to fix hundreds of pre-existing health findings at once. Ratchet +instead: + +```bash +roe health --write-baseline .roe-baseline.json # record current debt; always exits 0 +git add .roe-baseline.json +roe health --baseline .roe-baseline.json # from now on: only NEW debt fails +``` + +Baselines match findings by `(kind, name)` — not line numbers — so ordinary +edits don't invalidate them. Stale entries produce a warning, which is the +cue to regenerate the baseline. `--write-baseline` conflicts with +`--baseline` by design: writing through a filter would record a lie. + +## Limitations to keep in mind + +- C# only. Razor/`.cshtml` files are not parsed — build the project first so + the generated sources in `obj/` stand in for them. +- Name-based static analysis can't see string-based reflection or external + consumers; declare those with `roots` / `libraryProjects` instead of + suppressing the findings. +- Files over 5 MB are skipped. diff --git a/src/commands/dead_code.rs b/src/commands/dead_code.rs index fe50393..49076a7 100644 --- a/src/commands/dead_code.rs +++ b/src/commands/dead_code.rs @@ -121,13 +121,11 @@ pub(crate) fn execute_extracted( .warnings .extend(context.warnings.iter().cloned()); - if let Some(resolved) = &context.config - && let Some(ignore) = &resolved.config.ignore - { + if let Some((patterns, dir)) = context.ignore_for(commands::Analysis::DeadCode) { suppress::apply_config_ignores( &mut analysis.result, - ignore, - &resolved.dir, + &patterns, + dir, &mut analysis.workspace.warnings, ); } diff --git a/src/commands/dupes.rs b/src/commands/dupes.rs index b8bc23d..e101425 100644 --- a/src/commands/dupes.rs +++ b/src/commands/dupes.rs @@ -109,13 +109,16 @@ pub(crate) fn execute(context: &Context, args: &DupesArgs) -> anyhow::Result Option<(&[String], &Path)> { - self.config.as_ref().and_then(|resolved| { - resolved - .config - .ignore - .as_ref() - .map(|patterns| (patterns.as_slice(), resolved.dir.as_path())) - }) + /// The union of the config's top-level `ignore` globs and the given + /// analysis's own list, paired with the directory they resolve against. + /// `None` when the union is empty. + /// + /// Built in one place so every consumer of an analysis's globs — filters, + /// summaries, footers — sees the same list and their counts can't drift. + pub fn ignore_for(&self, analysis: Analysis) -> Option<(Vec, &Path)> { + let resolved = self.config.as_ref()?; + let config = &resolved.config; + let scoped = match analysis { + Analysis::DeadCode => config.dead_code.as_ref().and_then(|c| c.ignore.as_deref()), + Analysis::Dupes => config.dupes.as_ref().and_then(|c| c.ignore.as_deref()), + Analysis::Health => config.health.as_ref().and_then(|c| c.ignore.as_deref()), + }; + + let mut patterns = config.ignore.clone().unwrap_or_default(); + patterns.extend(scoped.into_iter().flatten().cloned()); + + if patterns.is_empty() { + return None; + } + + Some((patterns, resolved.dir.as_path())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{DeadCodeConfig, DupesConfig, HealthConfig, ResolvedConfig, RoeConfig}; + + fn context_with(config: RoeConfig) -> Context { + Context { + root: PathBuf::from("/repo"), + config: Some(ResolvedConfig { + path: PathBuf::from("/repo/roe.json"), + dir: PathBuf::from("/repo"), + config, + }), + warnings: Vec::new(), + } + } + + #[test] + fn ignore_for_unions_top_level_and_scoped_globs() { + let context = context_with(RoeConfig { + ignore: Some(vec!["Generated/".to_string()]), + dupes: Some(DupesConfig { + ignore: Some(vec!["Legacy/".to_string()]), + ..Default::default() + }), + ..Default::default() + }); + + let (patterns, dir) = context.ignore_for(Analysis::Dupes).expect("a union"); + + assert_eq!( + patterns, + vec!["Generated/".to_string(), "Legacy/".to_string()] + ); + assert_eq!(dir, Path::new("/repo")); + } + + #[test] + fn ignore_for_scopes_each_list_to_its_own_analysis() { + let context = context_with(RoeConfig { + dead_code: Some(DeadCodeConfig { + ignore: Some(vec!["Plugins/".to_string()]), + }), + dupes: Some(DupesConfig { + ignore: Some(vec!["Legacy/".to_string()]), + ..Default::default() + }), + health: Some(HealthConfig { + ignore: Some(vec!["Models/".to_string()]), + ..Default::default() + }), + ..Default::default() + }); + + let (dead_code, _) = context.ignore_for(Analysis::DeadCode).expect("patterns"); + let (dupes, _) = context.ignore_for(Analysis::Dupes).expect("patterns"); + let (health, _) = context.ignore_for(Analysis::Health).expect("patterns"); + + assert_eq!(dead_code, vec!["Plugins/".to_string()]); + assert_eq!(dupes, vec!["Legacy/".to_string()]); + assert_eq!(health, vec!["Models/".to_string()]); + } + + #[test] + fn ignore_for_returns_none_when_nothing_is_configured() { + let no_config = Context { + root: PathBuf::from("/repo"), + config: None, + warnings: Vec::new(), + }; + assert!(no_config.ignore_for(Analysis::Dupes).is_none()); + + let empty = context_with(RoeConfig::default()); + assert!(empty.ignore_for(Analysis::Health).is_none()); + } + + #[test] + fn ignore_for_treats_an_empty_scoped_list_as_a_no_op() { + let context = context_with(RoeConfig { + dupes: Some(DupesConfig { + ignore: Some(Vec::new()), + ..Default::default() + }), + ..Default::default() + }); + + assert!(context.ignore_for(Analysis::Dupes).is_none()); } } diff --git a/src/config.rs b/src/config.rs index f3b27f4..843de6a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,9 @@ pub struct RoeConfig { /// matching files have all their findings suppressed. A pattern ending in /// `/` also matches everything under that directory. pub ignore: Option>, + /// Extra `ignore` globs applied only to `roe dead-code`. + #[serde(rename = "deadCode")] + pub dead_code: Option, /// Defaults for `roe dupes`' matching mode and thresholds, so a combined /// `roe check` — which takes no dupes flags — can be calibrated. pub dupes: Option, @@ -29,6 +32,15 @@ pub struct RoeConfig { pub health: Option, } +/// The `deadCode` block of a config file. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DeadCodeConfig { + /// Extra ignore globs applied only to `roe dead-code`, unioned with the + /// top-level `ignore` list and resolved with the same rules. + pub ignore: Option>, +} + /// The `health` block of a config file. Every field is optional; an absent /// one falls back to the built-in default. #[derive(Debug, Default, Clone, Deserialize)] @@ -44,6 +56,9 @@ pub struct HealthConfig { /// Path to a baseline file, relative to this config file's own directory /// the way `ignore` globs are. pub baseline: Option, + /// Extra ignore globs applied only to `roe health`, unioned with the + /// top-level `ignore` list and resolved with the same rules. + pub ignore: Option>, } /// The `dupes` block of a config file. Every field is optional; an absent one @@ -51,6 +66,9 @@ pub struct HealthConfig { #[derive(Debug, Default, Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct DupesConfig { + /// Extra ignore globs applied only to `roe dupes`, unioned with the + /// top-level `ignore` list and resolved with the same rules. + pub ignore: Option>, pub min_lines: Option, pub min_occurrences: Option, pub min_tokens: Option, @@ -729,6 +747,75 @@ mod tests { assert_eq!(dupes.min_tokens, Some(100)); } + #[test] + fn parses_dupes_ignore_globs() { + let config: RoeConfig = + serde_json::from_str(r#"{"dupes": {"ignore": ["Legacy/"]}}"#).expect("valid json"); + let dupes = config.dupes.expect("a dupes block"); + + assert_eq!(dupes.ignore, Some(vec!["Legacy/".to_string()])); + assert_eq!(dupes.min_tokens, None); + } + + #[test] + fn parses_health_ignore_globs() { + let config: RoeConfig = + serde_json::from_str(r#"{"health": {"ignore": ["**/GeneratedModels.cs"]}}"#) + .expect("valid json"); + let health = config.health.expect("a health block"); + + assert_eq!( + health.ignore, + Some(vec!["**/GeneratedModels.cs".to_string()]) + ); + } + + #[test] + fn parses_dead_code_ignore_globs() { + let config: RoeConfig = + serde_json::from_str(r#"{"deadCode": {"ignore": ["Handlers/"]}}"#).expect("valid json"); + let dead_code = config.dead_code.expect("a deadCode block"); + + assert_eq!(dead_code.ignore, Some(vec!["Handlers/".to_string()])); + } + + #[test] + fn parses_yaml_scoped_ignores() { + // The exact shape issue #32 asked for. + let config: RoeConfig = serde_yaml_ng::from_str(concat!( + "ignore:\n", + " - \"Generated/**\"\n", + "dupes:\n", + " ignore:\n", + " - \"**/DashboardGetPlatformPolicyMembers.cs\"\n", + "health:\n", + " ignore:\n", + " - \"**/GeneratedModels.cs\"\n", + )) + .expect("valid yaml"); + + assert_eq!(config.ignore, Some(vec!["Generated/**".to_string()])); + assert_eq!( + config.dupes.expect("a dupes block").ignore, + Some(vec!["**/DashboardGetPlatformPolicyMembers.cs".to_string()]) + ); + assert_eq!( + config.health.expect("a health block").ignore, + Some(vec!["**/GeneratedModels.cs".to_string()]) + ); + } + + #[test] + fn rejects_unknown_dead_code_fields() { + // `ignor` is a near-miss of the real `ignore` field — it must fail + // loudly rather than leave the user thinking a suppression is in + // force when it isn't. It is declared in `_typos.toml` so the spell + // checker leaves it alone. + let error = serde_json::from_str::(r#"{"deadCode": {"ignor": []}}"#) + .expect_err("typo should not be silently ignored"); + assert!(error.to_string().contains("unknown field")); + } + #[test] fn rejects_unknown_dupes_fields() { // `minToken` is a near-miss of the real `minTokens` field — it must diff --git a/tests/cli.rs b/tests/cli.rs index 508ce72..75cc18e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -108,6 +108,44 @@ fn config_ignore_yaml_filters_out_ignored_folder() { assert!(!stdout.contains("Ignored")); } +#[test] +fn dead_code_scoped_ignore_json_drops_findings_in_matching_files() { + let output = roe() + .args(["dead-code", &fixture("dead_code_scoped_ignore_json")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("StillDead"), "got {stdout}"); + assert!(!stdout.contains("Handlers"), "got {stdout}"); +} + +#[test] +fn dead_code_scoped_ignore_yaml_drops_findings_in_matching_files() { + let output = roe() + .args(["dead-code", &fixture("dead_code_scoped_ignore_yaml")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("StillDead"), "got {stdout}"); + assert!(!stdout.contains("Handlers"), "got {stdout}"); +} + +/// The scoped list must not leak into the other analyses: the ignored +/// handler's duplicate occurrence still counts. +#[test] +fn dead_code_scoped_ignore_leaves_dupes_findings_alone() { + let output = roe() + .args(["dupes", &fixture("dead_code_scoped_ignore_json")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("found 1 duplicate group"), "got {stdout}"); + assert!(stdout.contains("PingHandler.cs"), "got {stdout}"); +} + #[test] fn config_resolution_walks_up_to_parent_directory() { // roe.json lives at the fixture root; --path points at a nested @@ -295,6 +333,65 @@ fn dupes_config_ignore_yaml_drops_the_ignored_occurrence() { assert!(stdout.contains("no duplicate code found")); } +#[test] +fn dupes_scoped_ignore_json_drops_the_duplicate() { + // `Legacy/` comes from `dupes.ignore` and `Vendor/` from the top-level + // list; only their union leaves a single occurrence, which is below the + // occurrence threshold. + let output = roe() + .args(["dupes", &fixture("dupes_scoped_ignore_json")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("no duplicate code found"), "got {stdout}"); +} + +#[test] +fn dupes_scoped_ignore_yaml_drops_the_duplicate() { + let output = roe() + .args(["dupes", &fixture("dupes_scoped_ignore_yaml")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("no duplicate code found"), "got {stdout}"); +} + +/// The acceptance criterion of the scoped-ignore feature: suppressing an +/// intentional duplicate must not cost the file its dead-code coverage. +#[test] +fn dupes_scoped_ignore_leaves_dead_code_findings_alone() { + let output = roe() + .args(["dead-code", &fixture("dupes_scoped_ignore_json")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Legacy/OldShippingService.cs"), + "got {stdout}" + ); + // The top-level list still applies to every command. + assert!(!stdout.contains("Vendor"), "got {stdout}"); +} + +#[test] +fn dupes_scoped_ignore_leaves_health_findings_alone() { + let output = roe() + .args([ + "health", + &fixture("dupes_scoped_ignore_json"), + "--max-complexity", + "2", + ]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("OldShippingService"), "got {stdout}"); +} + #[test] fn dupes_config_thresholds_json_reveal_a_short_clone() { let output = roe() @@ -833,6 +930,69 @@ fn health_ignore_globs_drop_findings_and_the_cycles_they_touch() { ); } +#[test] +fn health_scoped_ignore_drops_findings_and_counts_the_exclusion() { + let output = roe() + .args([ + "health", + &fixture("health_scoped_ignore"), + "--format", + "json", + "--max-complexity", + "2", + ]) + .output() + .expect("command runs"); + + let stdout = normalize(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + + let names: Vec<&str> = parsed["findings"] + .as_array() + .expect("findings array") + .iter() + .filter_map(|finding| finding["name"].as_str()) + .collect(); + assert_eq!(names, vec!["Lib.KeptWidget.Branchy"], "got {stdout}"); + + let summary = &parsed["summary"]; + assert_eq!(summary["filesScanned"], 1, "got {stdout}"); + assert_eq!(summary["excluded"]["ignoredFiles"], 1, "got {stdout}"); +} + +#[test] +fn health_scoped_ignore_shows_in_the_footer() { + let output = roe() + .args([ + "health", + &fixture("health_scoped_ignore"), + "--max-complexity", + "2", + ]) + .output() + .expect("command runs"); + + let stdout = normalize(&output.stdout); + + assert!( + stdout.contains("excluded: 1 ignored file"), + "the footer must count what the scoped glob dropped, got:\n{stdout}" + ); +} + +/// The scoped list must not leak into the other analyses: the health-ignored +/// file's dead code is still reported. +#[test] +fn health_scoped_ignore_leaves_dead_code_findings_alone() { + let output = roe() + .args(["dead-code", &fixture("health_scoped_ignore")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Lib/Ignored.cs"), "got {stdout}"); +} + /// A writable path for a baseline the test owns. `CARGO_TARGET_TMPDIR` is /// per-crate and cleaned by `cargo clean`, so nothing leaks into the fixtures. fn scratch(name: &str) -> std::path::PathBuf { @@ -1164,6 +1324,24 @@ fn check_reads_the_dupes_config_for_the_combined_run() { assert!(stdout.contains("duplicate group"), "got {stdout}"); } +/// One shared context feeds all three analyses, so each must apply its own +/// scoped list on top of the top-level one. +#[test] +fn check_applies_scoped_ignores_per_analysis() { + let output = roe() + .args([&fixture("dupes_scoped_ignore_json")]) + .output() + .expect("command runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("no duplicate code found"), "got {stdout}"); + assert!( + stdout.contains("Legacy/OldShippingService.cs"), + "got {stdout}" + ); + assert!(!stdout.contains("Vendor"), "got {stdout}"); +} + /// The combined JSON report must state the mode that actually ran, not a /// hard-coded `exact`. #[test] diff --git a/tests/fixtures/dead_code_scoped_ignore_json/Handlers/PingHandler.cs b/tests/fixtures/dead_code_scoped_ignore_json/Handlers/PingHandler.cs new file mode 100644 index 0000000..751a2b9 --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_json/Handlers/PingHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; + +namespace Messaging.Handlers; + +// Matched only by the `deadCode.ignore` glob: this class is unused, but its +// finding must be dropped — while its retry-method occurrence must still +// appear in the duplicate report, because the scoped glob applies to +// dead-code alone. +internal class PingHandler +{ + public Task HandleAsync(string requestId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Handling ping {requestId}"); + await Task.CompletedTask; + }, maxAttempts: 2); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_json/Program.cs b/tests/fixtures/dead_code_scoped_ignore_json/Program.cs new file mode 100644 index 0000000..38d2c6a --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_json/Program.cs @@ -0,0 +1,10 @@ +namespace Messaging; + +public static class Program +{ + public static void Main() + { + var pings = new Services.PingService(); + pings.PingAsync("gateway-1").Wait(); + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_json/Services/PingService.cs b/tests/fixtures/dead_code_scoped_ignore_json/Services/PingService.cs new file mode 100644 index 0000000..3f169a5 --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_json/Services/PingService.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; + +namespace Messaging.Services; + +public class PingService +{ + public Task PingAsync(string hostId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Pinging {hostId}"); + await Task.CompletedTask; + }, maxAttempts: 3); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_json/StillDead.cs b/tests/fixtures/dead_code_scoped_ignore_json/StillDead.cs new file mode 100644 index 0000000..b795644 --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_json/StillDead.cs @@ -0,0 +1,10 @@ +namespace Messaging; + +// Not covered by any ignore glob, so this finding must survive the +// `deadCode.ignore` filtering that drops Handlers/. +internal class StillDead +{ + public void DoNothing() + { + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_json/roe.json b/tests/fixtures/dead_code_scoped_ignore_json/roe.json new file mode 100644 index 0000000..c48960e --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_json/roe.json @@ -0,0 +1,5 @@ +{ + "deadCode": { + "ignore": ["Handlers/"] + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_yaml/Handlers/PingHandler.cs b/tests/fixtures/dead_code_scoped_ignore_yaml/Handlers/PingHandler.cs new file mode 100644 index 0000000..751a2b9 --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_yaml/Handlers/PingHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; + +namespace Messaging.Handlers; + +// Matched only by the `deadCode.ignore` glob: this class is unused, but its +// finding must be dropped — while its retry-method occurrence must still +// appear in the duplicate report, because the scoped glob applies to +// dead-code alone. +internal class PingHandler +{ + public Task HandleAsync(string requestId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Handling ping {requestId}"); + await Task.CompletedTask; + }, maxAttempts: 2); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_yaml/Program.cs b/tests/fixtures/dead_code_scoped_ignore_yaml/Program.cs new file mode 100644 index 0000000..38d2c6a --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_yaml/Program.cs @@ -0,0 +1,10 @@ +namespace Messaging; + +public static class Program +{ + public static void Main() + { + var pings = new Services.PingService(); + pings.PingAsync("gateway-1").Wait(); + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_yaml/Services/PingService.cs b/tests/fixtures/dead_code_scoped_ignore_yaml/Services/PingService.cs new file mode 100644 index 0000000..3f169a5 --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_yaml/Services/PingService.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; + +namespace Messaging.Services; + +public class PingService +{ + public Task PingAsync(string hostId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Pinging {hostId}"); + await Task.CompletedTask; + }, maxAttempts: 3); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_yaml/StillDead.cs b/tests/fixtures/dead_code_scoped_ignore_yaml/StillDead.cs new file mode 100644 index 0000000..b795644 --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_yaml/StillDead.cs @@ -0,0 +1,10 @@ +namespace Messaging; + +// Not covered by any ignore glob, so this finding must survive the +// `deadCode.ignore` filtering that drops Handlers/. +internal class StillDead +{ + public void DoNothing() + { + } +} diff --git a/tests/fixtures/dead_code_scoped_ignore_yaml/roe.yaml b/tests/fixtures/dead_code_scoped_ignore_yaml/roe.yaml new file mode 100644 index 0000000..f30dde0 --- /dev/null +++ b/tests/fixtures/dead_code_scoped_ignore_yaml/roe.yaml @@ -0,0 +1,3 @@ +deadCode: + ignore: + - "Handlers/" diff --git a/tests/fixtures/dupes_scoped_ignore_json/Legacy/OldShippingService.cs b/tests/fixtures/dupes_scoped_ignore_json/Legacy/OldShippingService.cs new file mode 100644 index 0000000..d5911c6 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_json/Legacy/OldShippingService.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; + +namespace Billing.Legacy; + +// Matched only by the `dupes.ignore` glob: its retry-method occurrence must +// vanish from the duplicate report, while this file's dead-code finding and +// the health finding for Route stay. +public class OldShippingService +{ + public Task DispatchAsync(string orderId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Dispatching order {orderId}"); + await Task.CompletedTask; + }, maxAttempts: 5); + } + + public string Route(bool express, bool oversized, bool fragile) + { + if (express) + { + return "air"; + } + else if (oversized && fragile) + { + return "special"; + } + + return "ground"; + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} + +internal class LegacyLedger +{ + public void DoNothing() + { + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_json/PaymentService.cs b/tests/fixtures/dupes_scoped_ignore_json/PaymentService.cs new file mode 100644 index 0000000..6117741 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_json/PaymentService.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; + +namespace Billing; + +public class PaymentService +{ + public Task ChargeAsync(string customerId, decimal amount) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Charging {customerId} for {amount:C}"); + await Task.CompletedTask; + }, maxAttempts: 3); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_json/Program.cs b/tests/fixtures/dupes_scoped_ignore_json/Program.cs new file mode 100644 index 0000000..79b6449 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_json/Program.cs @@ -0,0 +1,10 @@ +namespace Billing; + +public static class Program +{ + public static void Main() + { + var payments = new PaymentService(); + payments.ChargeAsync("customer-1", 25m).Wait(); + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_json/Vendor/VendorShipping.cs b/tests/fixtures/dupes_scoped_ignore_json/Vendor/VendorShipping.cs new file mode 100644 index 0000000..14fcaa9 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_json/Vendor/VendorShipping.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; + +namespace Billing.Vendor; + +// Matched by the top-level `ignore` glob, which applies to every command: +// neither the duplicate occurrence nor this file's dead-code finding may +// appear anywhere. +public class VendorShipping +{ + public Task ShipAsync(string parcelId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Shipping parcel {parcelId}"); + await Task.CompletedTask; + }, maxAttempts: 4); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} + +internal class VendorLedger +{ + public void DoNothing() + { + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_json/roe.json b/tests/fixtures/dupes_scoped_ignore_json/roe.json new file mode 100644 index 0000000..97304db --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_json/roe.json @@ -0,0 +1,6 @@ +{ + "ignore": ["Vendor/"], + "dupes": { + "ignore": ["Legacy/"] + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_yaml/Legacy/OldShippingService.cs b/tests/fixtures/dupes_scoped_ignore_yaml/Legacy/OldShippingService.cs new file mode 100644 index 0000000..d5911c6 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_yaml/Legacy/OldShippingService.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; + +namespace Billing.Legacy; + +// Matched only by the `dupes.ignore` glob: its retry-method occurrence must +// vanish from the duplicate report, while this file's dead-code finding and +// the health finding for Route stay. +public class OldShippingService +{ + public Task DispatchAsync(string orderId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Dispatching order {orderId}"); + await Task.CompletedTask; + }, maxAttempts: 5); + } + + public string Route(bool express, bool oversized, bool fragile) + { + if (express) + { + return "air"; + } + else if (oversized && fragile) + { + return "special"; + } + + return "ground"; + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} + +internal class LegacyLedger +{ + public void DoNothing() + { + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_yaml/PaymentService.cs b/tests/fixtures/dupes_scoped_ignore_yaml/PaymentService.cs new file mode 100644 index 0000000..6117741 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_yaml/PaymentService.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; + +namespace Billing; + +public class PaymentService +{ + public Task ChargeAsync(string customerId, decimal amount) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Charging {customerId} for {amount:C}"); + await Task.CompletedTask; + }, maxAttempts: 3); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_yaml/Program.cs b/tests/fixtures/dupes_scoped_ignore_yaml/Program.cs new file mode 100644 index 0000000..79b6449 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_yaml/Program.cs @@ -0,0 +1,10 @@ +namespace Billing; + +public static class Program +{ + public static void Main() + { + var payments = new PaymentService(); + payments.ChargeAsync("customer-1", 25m).Wait(); + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_yaml/Vendor/VendorShipping.cs b/tests/fixtures/dupes_scoped_ignore_yaml/Vendor/VendorShipping.cs new file mode 100644 index 0000000..14fcaa9 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_yaml/Vendor/VendorShipping.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; + +namespace Billing.Vendor; + +// Matched by the top-level `ignore` glob, which applies to every command: +// neither the duplicate occurrence nor this file's dead-code finding may +// appear anywhere. +public class VendorShipping +{ + public Task ShipAsync(string parcelId) + { + return ExecuteWithRetryAsync(async () => + { + Console.WriteLine($"Shipping parcel {parcelId}"); + await Task.CompletedTask; + }, maxAttempts: 4); + } + + private async Task ExecuteWithRetryAsync(Func action, int maxAttempts) + { + var attempt = 0; + + while (true) + { + try + { + await action(); + return; + } + catch (Exception exception) when (attempt < maxAttempts) + { + attempt++; + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + Console.WriteLine($"Retry {attempt} after {exception.Message}, waiting {delay}"); + await Task.Delay(delay); + } + } + } +} + +internal class VendorLedger +{ + public void DoNothing() + { + } +} diff --git a/tests/fixtures/dupes_scoped_ignore_yaml/roe.yaml b/tests/fixtures/dupes_scoped_ignore_yaml/roe.yaml new file mode 100644 index 0000000..7cf1f25 --- /dev/null +++ b/tests/fixtures/dupes_scoped_ignore_yaml/roe.yaml @@ -0,0 +1,5 @@ +ignore: + - "Vendor/" +dupes: + ignore: + - "Legacy/" diff --git a/tests/fixtures/health_scoped_ignore/Lib/Ignored.cs b/tests/fixtures/health_scoped_ignore/Lib/Ignored.cs new file mode 100644 index 0000000..4162474 --- /dev/null +++ b/tests/fixtures/health_scoped_ignore/Lib/Ignored.cs @@ -0,0 +1,41 @@ +namespace Lib; + +// Matched by the config's `health.ignore` glob. The findings in here and the +// cycle entirely contained in here must be dropped from the health report — +// while this file's dead-code finding survives, because the scoped glob +// applies to health alone. +public class IgnoredWidget +{ + public void Branchy(bool a, bool b, bool c) + { + if (a) + { + DoA(); + } + else if (b && c) + { + DoB(); + } + } + + private void DoA() { } + + private void DoB() { } +} + +public class IgnoredGamma +{ + public IgnoredDelta Delta; +} + +public class IgnoredDelta +{ + public IgnoredGamma Gamma; +} + +internal class IgnoredLedger +{ + public void DoNothing() + { + } +} diff --git a/tests/fixtures/health_scoped_ignore/Lib/Kept.cs b/tests/fixtures/health_scoped_ignore/Lib/Kept.cs new file mode 100644 index 0000000..7e598ba --- /dev/null +++ b/tests/fixtures/health_scoped_ignore/Lib/Kept.cs @@ -0,0 +1,32 @@ +namespace Lib; + +// Neither this file nor its cycle is covered by the config's ignore glob, so +// both must survive the filtering that drops Ignored.cs. +public class KeptWidget +{ + public void Branchy(bool a, bool b, bool c) + { + if (a) + { + DoA(); + } + else if (b && c) + { + DoB(); + } + } + + private void DoA() { } + + private void DoB() { } +} + +public class KeptAlpha +{ + public KeptBeta Beta; +} + +public class KeptBeta +{ + public KeptAlpha Alpha; +} diff --git a/tests/fixtures/health_scoped_ignore/Lib/Lib.csproj b/tests/fixtures/health_scoped_ignore/Lib/Lib.csproj new file mode 100644 index 0000000..c92aa9b --- /dev/null +++ b/tests/fixtures/health_scoped_ignore/Lib/Lib.csproj @@ -0,0 +1,7 @@ + + + Exe + net8.0 + enable + + diff --git a/tests/fixtures/health_scoped_ignore/roe.json b/tests/fixtures/health_scoped_ignore/roe.json new file mode 100644 index 0000000..cd45471 --- /dev/null +++ b/tests/fixtures/health_scoped_ignore/roe.json @@ -0,0 +1,5 @@ +{ + "health": { + "ignore": ["Lib/Ignored.cs"] + } +}