diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dafe371..829d7de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ for historical reference. ### Added +- `bca report markdown|html` now honors in-source suppression markers + (`bca: suppress`, `bca: suppress-file`, `#lizard forgives`) **by + default**, omitting a function from a metric's hotspot table when that + metric is suppressed for it — matching `bca check` and the SARIF + emitter (the report previously listed raw values and re-surfaced every + silenced function). Suppression is per-metric and folds the file's + `suppress-file` scope into each function's own scope. `bca report + --no-suppress` (or `[report] no_suppress = true` in `bca.toml`) opts + into the raw audit view that lists every offender. Advisory roll-ups + (the actionable summary, CC-stats note) intentionally keep counting + raw measurements. `SuppressionScope::merge` is now `pub` (additive) so + report consumers can fold scopes + ([#501](https://github.com/dekobon/big-code-analysis/issues/501)). - `bca check --report-suppressed`: surface the debt the gate tolerates in the code-scan document instead of dropping it. Offenders silenced by an in-source `bca: suppress` marker or covered by the baseline stay out of @@ -423,6 +436,13 @@ for historical reference. ### Changed +- Python bindings: `lang_to_name` now delegates to `LANG::get_name()` + for all but three lookup-token overrides (`Cpp` → `"cpp"`, `Csharp` → + `"csharp"`, `Tsx` → `"tsx"`), collapsing a 22-arm hand-maintained + table that duplicated the upstream CLI display names. The Python-facing + `language` identifiers are byte-identical for every variant; this only + removes drift risk between the facade and the CLI display names + ([#500](https://github.com/dekobon/big-code-analysis/issues/500)). - **(breaking)** `FilesData` and `ConcurrentRunner` are reshaped into a terminal file-set processor: `FilesData` drops its `include` / `exclude` `GlobSet` fields (now just `FilesData { paths }`), diff --git a/big-code-analysis-book/src/commands/report.md b/big-code-analysis-book/src/commands/report.md index 0811e9c3..69eca68d 100644 --- a/big-code-analysis-book/src/commands/report.md +++ b/big-code-analysis-book/src/commands/report.md @@ -37,8 +37,36 @@ bca --paths /path/to/project report markdown --output report.md | --- | --- | --- | | `--top N` | 20 | Maximum entries per hotspot table. | | `--strip-prefix PATH` | *(empty)* | Prefix removed from file paths. | +| `--no-suppress` | *(off)* | Include functions silenced by in-source suppression markers (raw audit view). | | `-o, --output FILE` | *(stdout)* | Output file. Parent directory must exist. | +## Suppression markers + +By default, `bca report markdown|html` **honours** in-source suppression +markers — the same `// bca: suppress`, `// bca: suppress-file`, and +`#lizard forgives` comments that [`bca check`](check.md) and the SARIF +emitter respect (see [Suppression](suppression.md)). A function is +omitted from a metric's hotspot table when that metric is suppressed for +it, so the published report agrees with the threshold gate instead of +re-surfacing every silenced offender. + +Suppression is per-metric: a `// bca: suppress(cyclomatic)` marker drops +the function from the Cyclomatic table only — it still appears in the +Cognitive, Halstead, and other tables. A bare `// bca: suppress` (or +`// bca: suppress-file`) covers every metric. + +Pass `--no-suppress` for the raw audit view that lists every offender +regardless of markers. The setting can also be pinned in the +[`bca.toml` manifest](check.md): + +```toml +[report] +no_suppress = true +``` + +The CLI flag wins; a bare `--no-suppress` can force the audit view on, +but the manifest never forces it off. + ## Examples Show only the five worst hotspots per section: diff --git a/big-code-analysis-book/src/commands/suppression.md b/big-code-analysis-book/src/commands/suppression.md index 4cb1b282..3c476269 100644 --- a/big-code-analysis-book/src/commands/suppression.md +++ b/big-code-analysis-book/src/commands/suppression.md @@ -4,9 +4,12 @@ In-source suppression markers silence threshold violations without editing the offending function or excluding the file from the walk. Drop a marker in any comment in the source file and `bca check` treats the covered metrics as if they were within limits for that -scope. Metric computation is unaffected — raw `bca metrics` / -`bca report` output still reports every number. Suppression is a -threshold-check concern only. +scope. Metric computation is unaffected — raw `bca metrics` output +still reports every number. Suppression is a measurement-display +concern: `bca check` drops the covered violations from the gate, and +`bca report markdown|html` omits the covered functions from the +matching hotspot tables by default (pass `bca report --no-suppress` +for the raw audit view — see [report](report.md)). Markers exist for the cases editing the code is not an option: generated-style legacy modules awaiting rewrite, accepted exceptions @@ -179,9 +182,10 @@ offender list: bca --paths src/ check --no-suppress ``` -The flag has no effect on metric values themselves: raw -`bca metrics` / `bca report` output already ignores markers, since -suppression is a threshold-check concern only. +The flag has no effect on metric values themselves: raw `bca metrics` +output always reports every number. `bca report markdown|html` honours +markers in its hotspot tables by default and accepts its own +[`--no-suppress`](report.md) flag for the same raw audit view. ## Surfacing suppressed debt (`--report-suppressed`) diff --git a/big-code-analysis-cli/src/commands.rs b/big-code-analysis-cli/src/commands.rs index 85c968e8..2f88e553 100644 --- a/big-code-analysis-cli/src/commands.rs +++ b/big-code-analysis-cli/src/commands.rs @@ -1422,7 +1422,9 @@ pub fn run() { Command::Functions => run_command_functions(cli.globals, preproc), Command::Metrics(args) => run_command_metrics(cli.globals, args, preproc), Command::Ops(args) => run_command_ops(cli.globals, args, preproc), - Command::Report(args) => run_command_report(cli.globals, args, preproc), + Command::Report(args) => { + run_command_report(cli.globals, args, manifest.as_ref(), preproc); + } Command::Find(args) => run_command_find(cli.globals, args, preproc), Command::Count(args) => run_command_count(cli.globals, args, preproc), Command::StripComments(args) => run_command_strip_comments(cli.globals, args, preproc), @@ -1563,7 +1565,16 @@ fn run_command_ops( run_walk(globals, cfg); } -fn run_command_report(globals: GlobalOpts, args: ReportArgs, preproc: Option>) { +fn run_command_report( + globals: GlobalOpts, + mut args: ReportArgs, + manifest: Option<&Manifest>, + preproc: Option>, +) { + if let Some(m) = manifest { + m.merge_report(&mut args); + } + let policy = SuppressionPolicy::from_no_suppress(args.no_suppress); if let Some(ref output) = args.output { if output.exists() && output.is_dir() { die("--output must be a file path for `report`"); @@ -1590,8 +1601,8 @@ fn run_command_report(globals: GlobalOpts, args: ReportArgs, preproc: Option = rx.into_iter().collect(); let report = match args.format { - ReportFormat::Markdown => generate_report(&summaries, args.top as usize), - ReportFormat::Html => generate_html_report(&summaries, args.top as usize), + ReportFormat::Markdown => generate_report(&summaries, args.top as usize, policy), + ReportFormat::Html => generate_html_report(&summaries, args.top as usize, policy), }; if let Some(ref output_path) = args.output { std::fs::write(output_path, &report) @@ -1739,6 +1750,16 @@ nargs = 7 nexits = 5 abc = 50 wmc = 60 + +# Aggregated-report options for `bca report markdown|html` (#501). By +# default the report honours in-source suppression markers +# (`bca: suppress`, `bca: suppress-file`, `#lizard forgives`) and omits a +# function from a metric's hotspot table when that metric is suppressed +# for it — matching `bca check` and the SARIF emitter. Uncomment to opt +# into the raw audit view that lists every offender (equivalent to +# `bca report --no-suppress`). +# [report] +# no_suppress = true "; /// Canonical contents of a freshly-scaffolded `.bcaignore`. The diff --git a/big-code-analysis-cli/src/html_report.rs b/big-code-analysis-cli/src/html_report.rs index 41e99cf7..ed033d1c 100644 --- a/big-code-analysis-cli/src/html_report.rs +++ b/big-code-analysis-cli/src/html_report.rs @@ -38,7 +38,7 @@ use std::borrow::Cow; use std::collections::BTreeMap; use std::fmt::Write; -use big_code_analysis::SpaceKind; +use big_code_analysis::{MetricKind, SpaceKind, SuppressionPolicy}; use crate::format_util::MetricScalar; use crate::markdown_report::{ @@ -319,6 +319,12 @@ struct HotspotSpec { metric: fn(&FunctionSummary) -> f64, dir: SortDir, columns: &'static [HotspotColumn], + /// Which metric family this table ranks. When the report honors + /// suppression markers (the default), an entry is dropped if its + /// effective scope covers this kind — the same per-table filtering + /// the Markdown report applies (issue #501). `--no-suppress` + /// (`SuppressionPolicy::Ignore`) bypasses it. + metric_kind: MetricKind, } // Column descriptors shared verbatim across multiple hotspot specs. @@ -365,6 +371,7 @@ const MI_LOWEST_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.mi_visual_studio > 0.0, metric: |s| s.mi_visual_studio, dir: SortDir::Asc, + metric_kind: MetricKind::Mi, columns: &[ COL_FILE, HotspotColumn { @@ -381,6 +388,7 @@ const CC_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.cyclomatic > 0.0, metric: |s| s.cyclomatic, dir: SortDir::Desc, + metric_kind: MetricKind::Cyclomatic, columns: &[ COL_FUNCTION, COL_FILE, @@ -396,6 +404,7 @@ const COGNITIVE_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.cognitive > 0.0, metric: |s| s.cognitive, dir: SortDir::Desc, + metric_kind: MetricKind::Cognitive, columns: &[ COL_FUNCTION, COL_FILE, @@ -411,6 +420,7 @@ const HALSTEAD_EFFORT_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.halstead_effort > 0.0, metric: |s| s.halstead_effort, dir: SortDir::Desc, + metric_kind: MetricKind::Halstead, columns: &[ COL_FUNCTION, COL_FILE, @@ -438,6 +448,7 @@ const LARGEST_BY_SLOC_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.sloc > 0, metric: |s| s.sloc as f64, dir: SortDir::Desc, + metric_kind: MetricKind::Loc, columns: &[ COL_FUNCTION, COL_FILE, @@ -453,6 +464,7 @@ const MANY_PARAMS_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.nargs > 3, metric: |s| s.nargs as f64, dir: SortDir::Desc, + metric_kind: MetricKind::Nargs, columns: &[ COL_FUNCTION, COL_FILE, @@ -475,6 +487,7 @@ const WMC_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| is_class_like(s.kind) && s.wmc > 0.0, metric: |s| s.wmc, dir: SortDir::Desc, + metric_kind: MetricKind::Wmc, columns: &[ HotspotColumn { header: "Class", @@ -508,10 +521,15 @@ const WMC_HOTSPOT: HotspotSpec = HotspotSpec { ], }; +// The exit-points table maps to `MetricKind::Exit` — the suppression +// vocabulary's spelling of the threshold engine's `nexits` (matching +// `MetricKind::for_threshold_name`'s `nexits → exit` alias), so a +// `bca: suppress(exit)` marker silences it. const NEXITS_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.nexits > 0, metric: |s| s.nexits as f64, dir: SortDir::Desc, + metric_kind: MetricKind::Exit, columns: &[ COL_FUNCTION, COL_FILE, @@ -531,6 +549,7 @@ const ABC_HOTSPOT: HotspotSpec = HotspotSpec { keep: |s| s.abc > 0.0, metric: |s| s.abc, dir: SortDir::Desc, + metric_kind: MetricKind::Abc, columns: &[ COL_FUNCTION, COL_FILE, @@ -617,9 +636,13 @@ fn emit_hotspot( base: &[&FunctionSummary], top_n: usize, spec: &HotspotSpec, + policy: SuppressionPolicy, ) -> bool { - let mut entries: Vec<&FunctionSummary> = - base.iter().copied().filter(|s| (spec.keep)(s)).collect(); + let mut entries: Vec<&FunctionSummary> = base + .iter() + .copied() + .filter(|s| (spec.keep)(s) && !s.is_hidden_for(spec.metric_kind, policy)) + .collect(); if entries.is_empty() { return false; } @@ -838,7 +861,11 @@ fn write_html_tail(out: &mut String) { /// Produce a self-contained HTML quality-metrics report from the /// collected summaries. `top_n` controls how many entries appear in /// each hotspot table. -pub(crate) fn generate_html_report(summaries: &[FunctionSummary], top_n: usize) -> String { +pub(crate) fn generate_html_report( + summaries: &[FunctionSummary], + top_n: usize, + policy: SuppressionPolicy, +) -> String { // Each summary contributes at most one row across all hotspot // tables (sections × top_n is bounded), but the per-language // overview table plus the inline CSS/JS already costs a few KB of @@ -854,7 +881,7 @@ pub(crate) fn generate_html_report(summaries: &[FunctionSummary], top_n: usize) if !by_lang.is_empty() { write_overview_table(&mut out, &by_lang); for (&lang_name, lang_summaries) in &by_lang { - write_language_section(&mut out, lang_name, lang_summaries, top_n); + write_language_section(&mut out, lang_name, lang_summaries, top_n, policy); } } write_html_tail(&mut out); @@ -1001,7 +1028,12 @@ impl CyclomaticStats { /// stats are computed first so the note can be gated on the table /// actually rendering — an empty `funcs` slice yields no table and no /// misleading `Average CC: 0.0` line. -fn emit_cc_hotspot_with_stats(out: &mut String, funcs: &[&FunctionSummary], top_n: usize) { +fn emit_cc_hotspot_with_stats( + out: &mut String, + funcs: &[&FunctionSummary], + top_n: usize, + policy: SuppressionPolicy, +) { let stats = CyclomaticStats::from_funcs(funcs); if emit_hotspot( out, @@ -1009,6 +1041,7 @@ fn emit_cc_hotspot_with_stats(out: &mut String, funcs: &[&FunctionSummary], top_ funcs, top_n, &CC_HOTSPOT, + policy, ) { let _ = writeln!( out, @@ -1109,6 +1142,7 @@ fn write_language_section( lang_name: &str, entries: &[&FunctionSummary], top_n: usize, + policy: SuppressionPolicy, ) { write_language_header(out, lang_name); let (units, funcs) = partition_by_kind(entries); @@ -1120,14 +1154,16 @@ fn write_language_section( &units, top_n, &MI_LOWEST_HOTSPOT, + policy, ); - emit_cc_hotspot_with_stats(out, &funcs, top_n); + emit_cc_hotspot_with_stats(out, &funcs, top_n, policy); emit_hotspot( out, "Cognitive Complexity Hotspots", &funcs, top_n, &COGNITIVE_HOTSPOT, + policy, ); emit_hotspot( out, @@ -1135,6 +1171,7 @@ fn write_language_section( &funcs, top_n, &HALSTEAD_EFFORT_HOTSPOT, + policy, ); emit_hotspot( out, @@ -1142,6 +1179,7 @@ fn write_language_section( &funcs, top_n, &LARGEST_BY_SLOC_HOTSPOT, + policy, ); emit_hotspot( out, @@ -1149,6 +1187,7 @@ fn write_language_section( &funcs, top_n, &MANY_PARAMS_HOTSPOT, + policy, ); write_actionable_summary(out, &funcs); // WMC sources `entries` (all kinds), not `funcs`: class-likes are @@ -1159,6 +1198,7 @@ fn write_language_section( entries, top_n, &WMC_HOTSPOT, + policy, ); emit_hotspot( out, @@ -1166,8 +1206,16 @@ fn write_language_section( &funcs, top_n, &NEXITS_HOTSPOT, + policy, + ); + emit_hotspot( + out, + "ABC Magnitude Hotspots", + &funcs, + top_n, + &ABC_HOTSPOT, + policy, ); - emit_hotspot(out, "ABC Magnitude Hotspots", &funcs, top_n, &ABC_HOTSPOT); let _ = out.write_str("\n"); } @@ -1205,6 +1253,7 @@ mod tests { name: name.to_string(), kind, language, + suppressed: big_code_analysis::SuppressionScope::default(), start_line: 1, end_line: 10, sloc: 20, @@ -1271,7 +1320,7 @@ mod tests { #[test] fn empty_summaries_emit_no_tables() { - let out = generate_html_report(&[], 20); + let out = generate_html_report(&[], 20, SuppressionPolicy::Honor); assert!(out.contains("

Code Quality Metrics Summary

")); assert!(!out.contains("10,000<") && out.contains(">1,500,000<"), "expected thousands-formatted cells in output" @@ -1327,7 +1376,7 @@ mod tests { #[test] fn single_language_well_formed() { - let out = generate_html_report(&rust_fixture(), 20); + let out = generate_html_report(&rust_fixture(), 20, SuppressionPolicy::Honor); assert!(out.contains("

Rust

")); assert!(out.contains("class=\"hotspot\"")); assert_html_well_formed(&out); @@ -1335,7 +1384,7 @@ mod tests { #[test] fn two_language_well_formed_and_alphabetical() { - let out = generate_html_report(&two_lang_fixture(), 20); + let out = generate_html_report(&two_lang_fixture(), 20, SuppressionPolicy::Honor); assert!(out.contains("

Python

")); assert!(out.contains("

Rust

")); let py = out.find("

Python

").expect("python heading"); @@ -1353,7 +1402,7 @@ mod tests { summaries[1].name = "".to_string(); summaries[1].file = "a&b\"c'd".to_string(); - let out = generate_html_report(&summaries, 20); + let out = generate_html_report(&summaries, 20, SuppressionPolicy::Honor); assert!( !out.contains("