use crate::bib::{self, Library};
use crate::catalogue;
use crate::config::{ConfidenceTier, DoiResolver, ToolConfig};
use crate::report::{CategorySummary, ComplianceReport, SkippedRule, Violation};
use crate::rules::{
abbreviations, capitalization, citations, code_style, code_width, crossrefs, house_style,
markup, naming, operators, preamble, references, structure, typography,
};
use crate::tex::node::Node as TexNode;
use crate::tex::position::LineIndex;
use crate::tex::{self, ParsedTex};
use std::collections::HashMap;
#[derive(Debug)]
pub enum EngineError {
UnsupportedSuffix(String),
}
impl std::fmt::Display for EngineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EngineError::UnsupportedSuffix(msg) => write!(f, "{msg}"),
}
}
}
pub struct ParsedTexFileDoc {
pub path: String,
pub parsed: ParsedTex,
pub line_index: LineIndex,
pub violations: Vec<Violation>,
}
pub struct ParsedBibFileDoc {
pub path: String,
pub source_chars: Vec<char>,
pub library: Library,
pub violations: Vec<Violation>,
}
pub struct ParsedRmdFileDoc {
pub path: String,
pub latex_fragments: Vec<ParsedTexFileDoc>,
pub violations: Vec<Violation>,
}
#[derive(Default)]
pub struct ParsedDocument {
pub tex_files: Vec<ParsedTexFileDoc>,
pub bib_files: Vec<ParsedBibFileDoc>,
pub rmd_files: Vec<ParsedRmdFileDoc>,
}
impl ParsedDocument {
pub fn from_sources(files: &[(String, String)]) -> Result<Self, EngineError> {
let mut doc = ParsedDocument::default();
for (path, source) in files {
let source: &str = source.strip_prefix('\u{FEFF}').unwrap_or(source);
let lower = path.to_lowercase();
if lower.ends_with(".tex") || lower.ends_with(".ltx") {
let parsed = tex::parse_tex_source(source);
let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
doc.tex_files.push(ParsedTexFileDoc {
path: path.clone(),
parsed,
line_index,
violations: Vec::new(),
});
} else if lower.ends_with(".bib") {
let library = bib::parse(source);
doc.bib_files.push(ParsedBibFileDoc {
path: path.clone(),
source_chars: source.chars().collect(),
library,
violations: Vec::new(),
});
} else if lower.ends_with(".rnw") {
let rewritten = crate::rnw::wrap_rnw_chunks_as_sinput(source);
let parsed = tex::parse_tex_source(&rewritten);
let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
doc.tex_files.push(ParsedTexFileDoc {
path: path.clone(),
parsed,
line_index,
violations: Vec::new(),
});
} else if lower.ends_with(".rmd") {
doc.rmd_files
.push(crate::rmd::parse_rmd_source(path, source));
} else {
let name = path.rsplit('/').next().unwrap_or(path.as_str());
return Err(EngineError::UnsupportedSuffix(format!(
"unsupported file extension: '{name}'. Supported: .tex, .ltx, .bib, .Rnw, .Rmd (case-insensitive)."
)));
}
}
Ok(doc)
}
pub fn attach_violation(&mut self, path: &str, violation: Violation) {
for tf in &mut self.tex_files {
if tf.path == path {
tf.violations.push(violation);
return;
}
}
for bf in &mut self.bib_files {
if bf.path == path {
bf.violations.push(violation);
return;
}
}
for rf in &mut self.rmd_files {
if rf.path == path {
rf.violations.push(violation);
return;
}
}
}
pub(crate) fn all_tex_like_docs(&self) -> impl Iterator<Item = &ParsedTexFileDoc> {
self.tex_files
.iter()
.chain(self.rmd_files.iter().flat_map(|r| r.latex_fragments.iter()))
}
fn tex_like(&self) -> Vec<&[TexNode]> {
self.all_tex_like_docs()
.map(|t| t.parsed.nodes.as_slice())
.collect()
}
fn tex_file_line_indexes(&self) -> Vec<(&str, &LineIndex)> {
self.all_tex_like_docs()
.map(|t| (t.path.as_str(), &t.line_index))
.collect()
}
}
type TexCheckFn = fn(&str, &ParsedTex, u32) -> Vec<Violation>;
type BibCheckFn = fn(
&str,
&[char],
&Library,
&[&[TexNode]],
&[(&str, &LineIndex)],
Option<&DoiResolver>,
) -> Vec<Violation>;
type TexGlobalCheckFn = fn(&[(&str, &ParsedTex)]) -> Vec<Violation>;
struct TexRuleEntry {
id: &'static str,
check: TexCheckFn,
restricted_to_tex: bool,
scans_rmd_prose: bool,
}
struct BibRuleEntry {
id: &'static str,
check: BibCheckFn,
}
struct GlobalTexRuleEntry {
id: &'static str,
check: TexGlobalCheckFn,
}
mod rules_registry {
use super::*;
pub const TEX_RULES: &[TexRuleEntry] = &[
TexRuleEntry {
id: "JSS-WIDTH-001",
check: |f, p, w| code_width::check_width_001(f, p, w),
restricted_to_tex: false,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-CODE-001",
check: |f, p, _w| code_style::check_code_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-CODE-002",
check: |f, p, _w| code_style::check_code_002(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-CODE-003",
check: |f, p, _w| code_style::check_code_003(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-ABBR-001",
check: |f, p, _w| abbreviations::check_abbr_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-TYPO-001",
check: |f, p, _w| typography::check_typo_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-TYPO-002",
check: |f, p, _w| typography::check_typo_002(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-TYPO-003",
check: |f, p, _w| typography::check_typo_003(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-TYPO-004",
check: |f, p, _w| typography::check_typo_004(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-CAP-002",
check: |f, p, _w| capitalization::check_cap_002(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-CAP-004",
check: |f, p, _w| capitalization::check_cap_004(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-STRUCT-001",
check: |f, p, _w| structure::check_struct_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-STRUCT-002",
check: |f, p, _w| structure::check_struct_002(f, p),
restricted_to_tex: false,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-STRUCT-003",
check: |f, p, _w| structure::check_struct_003(f, p),
restricted_to_tex: false,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-STRUCT-004",
check: |f, p, _w| structure::check_struct_004(f, p),
restricted_to_tex: false,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-STRUCT-005",
check: |f, p, _w| structure::check_struct_005(f, p),
restricted_to_tex: false,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-STRUCT-006",
check: |f, p, _w| structure::check_struct_006(f, p),
restricted_to_tex: false,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-OPER-001",
check: |f, p, _w| operators::check_oper_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-OPER-002",
check: |f, p, _w| operators::check_oper_002(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-OPER-003",
check: |f, p, _w| operators::check_oper_003(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-HOUSE-001",
check: |f, p, _w| house_style::check_house_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-HOUSE-003",
check: |f, p, _w| house_style::check_house_003(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-NAME-001",
check: |f, p, _w| naming::check_name_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-PRE-001",
check: |f, p, _w| preamble::check_pre_001(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-PRE-002",
check: |f, p, _w| preamble::check_pre_002(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-PRE-003",
check: |f, p, _w| preamble::check_pre_003(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-PRE-004",
check: |f, p, _w| preamble::check_pre_004(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-PRE-005",
check: |f, p, _w| preamble::check_pre_005(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-PRE-006",
check: |f, p, _w| preamble::check_pre_006(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-PRE-007",
check: |f, p, _w| preamble::check_pre_007(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-PRE-008",
check: |f, p, _w| preamble::check_pre_008(f, p),
restricted_to_tex: true,
scans_rmd_prose: false,
},
TexRuleEntry {
id: "JSS-CITE-003",
check: |f, p, _w| citations::check_cite_003(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-CITE-004",
check: |f, p, _w| citations::check_cite_004(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-MARKUP-001",
check: |f, p, _w| markup::check_markup_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-MARKUP-002",
check: |f, p, _w| markup::check_markup_002(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-MARKUP-003",
check: |f, p, _w| markup::check_markup_003(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-MARKUP-004",
check: |f, p, _w| markup::check_markup_004(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-XREF-001",
check: |f, p, _w| crossrefs::check_xref_001(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-XREF-002",
check: |f, p, _w| crossrefs::check_xref_002(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-XREF-003",
check: |f, p, _w| crossrefs::check_xref_003(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-XREF-006",
check: |f, p, _w| crossrefs::check_xref_006(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
TexRuleEntry {
id: "JSS-XREF-007",
check: |f, p, _w| crossrefs::check_xref_007(f, p),
restricted_to_tex: false,
scans_rmd_prose: true,
},
];
pub const BIB_RULES: &[BibRuleEntry] = &[
BibRuleEntry {
id: "JSS-BIBTEX-001",
check: |f, _c, lib, tl, _tf, _r| crate::rules::bibtex::check_bibtex_001(f, lib, tl),
},
BibRuleEntry {
id: "JSS-BIBTEX-002",
check: |f, _c, lib, _tl, _tf, _r| crate::rules::bibtex::check_bibtex_002(f, lib),
},
BibRuleEntry {
id: "JSS-BIBTEX-003",
check: |f, _c, lib, tl, _tf, _r| crate::rules::bibtex::check_bibtex_003(f, lib, tl),
},
BibRuleEntry {
id: "JSS-BIBTEX-004",
check: |f, _c, lib, tl, tf, _r| crate::rules::bibtex::check_bibtex_004(f, tf, lib, tl),
},
BibRuleEntry {
id: "JSS-BIBTEX-005",
check: |f, _c, lib, _tl, _tf, _r| crate::rules::bibtex::check_bibtex_005(f, lib),
},
BibRuleEntry {
id: "JSS-NAME-002",
check: |f, c, lib, tl, _tf, _r| naming::check_name_002(f, c, lib, tl),
},
BibRuleEntry {
id: "JSS-HOUSE-002",
check: |f, c, lib, tl, _tf, _r| house_style::check_house_002(f, c, lib, tl),
},
BibRuleEntry {
id: "JSS-REFS-001",
check: |f, _c, lib, tl, _tf, _r| references::check_refs_001(f, lib, tl),
},
BibRuleEntry {
id: "JSS-REFS-003",
check: |f, c, lib, tl, _tf, r| references::check_refs_003(f, c, lib, tl, r),
},
BibRuleEntry {
id: "JSS-REFS-004",
check: |f, _c, lib, tl, _tf, _r| references::check_refs_004(f, lib, tl),
},
BibRuleEntry {
id: "JSS-REFS-005",
check: |f, _c, lib, tl, _tf, _r| references::check_refs_005(f, lib, tl),
},
BibRuleEntry {
id: "JSS-REFS-006",
check: |f, _c, lib, tl, _tf, _r| references::check_refs_006(f, lib, tl),
},
BibRuleEntry {
id: "JSS-REFS-007",
check: |f, _c, lib, tl, _tf, _r| references::check_refs_007(f, lib, tl),
},
];
pub const GLOBAL_TEX_RULES: &[GlobalTexRuleEntry] = &[
GlobalTexRuleEntry {
id: "JSS-CAP-001",
check: capitalization::check_cap_001,
},
GlobalTexRuleEntry {
id: "JSS-CITE-002",
check: citations::check_cite_002,
},
GlobalTexRuleEntry {
id: "JSS-OPER-004",
check: operators::check_oper_004,
},
GlobalTexRuleEntry {
id: "JSS-XREF-004",
check: crossrefs::check_xref_004,
},
GlobalTexRuleEntry {
id: "JSS-XREF-005",
check: crossrefs::check_xref_005,
},
];
}
pub(crate) fn python_list_repr(items: &[&str]) -> String {
let quoted: Vec<String> = items.iter().map(|s| format!("'{s}'")).collect();
format!("[{}]", quoted.join(", "))
}
fn python_round1(x: f64) -> f64 {
format!("{x:.1}").parse().unwrap_or(x)
}
fn category_title(category_id: &str) -> &'static str {
match category_id {
"preamble" => "Preamble",
"structure" => "Structure",
"markup" => "Markup",
"citations" => "Citations",
"references" => "References",
"bibtex" => "BibTeX",
"naming" => "Naming",
"capitalization" => "Capitalization",
"typography" => "Typography",
"abbreviations" => "Abbreviations",
"code_style" => "Code style",
"code_width" => "Code width",
"operators" => "Operators",
"crossrefs" => "Cross-references",
"house_style" => "House style",
"project" => "Project",
other => {
panic!("unknown category id {other:?} (not in journals/jss/__init__.py's _TITLE_MAP)")
}
}
}
fn confidence_meets_floor(rule_id: &str, floor: ConfidenceTier) -> (bool, &'static str) {
let confidence = catalogue::lookup(rule_id)
.map(|m| m.confidence)
.unwrap_or("high");
let tier = ConfidenceTier::parse(confidence).unwrap_or(ConfidenceTier::High);
(tier >= floor, confidence)
}
fn category_rank(category: &str) -> usize {
catalogue::categories()
.iter()
.position(|&c| c == category)
.unwrap_or(usize::MAX)
}
enum RuleAction<'a> {
Tex(&'a TexRuleEntry),
Bib(&'a BibRuleEntry),
TexGlobal(&'a GlobalTexRuleEntry),
}
fn ordered_rules() -> Vec<(&'static str, RuleAction<'static>)> {
let mut all: Vec<(usize, &'static str, RuleAction<'static>)> = Vec::new();
for entry in rules_registry::TEX_RULES {
let category = catalogue::lookup(entry.id)
.map(|m| m.category)
.unwrap_or("");
all.push((category_rank(category), entry.id, RuleAction::Tex(entry)));
}
for entry in rules_registry::BIB_RULES {
let category = catalogue::lookup(entry.id)
.map(|m| m.category)
.unwrap_or("");
all.push((category_rank(category), entry.id, RuleAction::Bib(entry)));
}
for entry in rules_registry::GLOBAL_TEX_RULES {
let category = catalogue::lookup(entry.id)
.map(|m| m.category)
.unwrap_or("");
all.push((
category_rank(category),
entry.id,
RuleAction::TexGlobal(entry),
));
}
all.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
all.into_iter()
.map(|(_, id, action)| (id, action))
.collect()
}
pub fn run(config: &ToolConfig, document: &ParsedDocument) -> ComplianceReport {
run_impl(config, document, None)
}
pub fn run_with_project(
config: &ToolConfig,
document: &ParsedDocument,
cycles: Vec<Violation>,
missing: Vec<Violation>,
) -> ComplianceReport {
run_impl(config, document, Some((cycles, missing)))
}
fn run_impl(
config: &ToolConfig,
document: &ParsedDocument,
project_extra: Option<(Vec<Violation>, Vec<Violation>)>,
) -> ComplianceReport {
let tex_like = document.tex_like();
let tex_file_line_indexes = document.tex_file_line_indexes();
let mut applied_by_category: HashMap<&'static str, u32> = HashMap::new();
let mut passed_by_category: HashMap<&'static str, u32> = HashMap::new();
let mut violations_by_category: HashMap<&'static str, Vec<Violation>> = HashMap::new();
let mut skipped: Vec<SkippedRule> = Vec::new();
let mut run_one = |rule_id: &'static str, mut rule_violations: Vec<Violation>| {
let Some(meta) = catalogue::lookup(rule_id) else {
return;
};
let category = meta.category;
if !config.severity_overrides.is_empty() {
for v in &mut rule_violations {
if let Some(&sev) = config.severity_overrides.get(rule_id) {
v.severity = sev;
}
}
}
*applied_by_category.entry(category).or_insert(0) += 1;
if rule_violations.is_empty() {
*passed_by_category.entry(category).or_insert(0) += 1;
}
violations_by_category
.entry(category)
.or_default()
.extend(rule_violations);
};
for (rule_id, action) in ordered_rules() {
if config.ignore_rules.contains(rule_id) {
continue;
}
let (meets_floor, confidence) = confidence_meets_floor(rule_id, config.min_confidence);
if !meets_floor {
skipped.push(SkippedRule {
rule_id: rule_id.to_string(),
reason: format!(
"confidence {confidence} below min_confidence={}",
config.min_confidence.as_str()
),
});
continue;
}
let has_any_file = !document.tex_files.is_empty()
|| !document.bib_files.is_empty()
|| !document.rmd_files.is_empty();
match action {
RuleAction::Tex(entry) => {
let should_run = if entry.restricted_to_tex {
!document.tex_files.is_empty()
} else {
has_any_file
};
if !should_run {
let mut input_formats: Vec<&str> = Vec::new();
if document
.tex_files
.iter()
.any(|tf| !tf.path.to_lowercase().ends_with(".rnw"))
{
input_formats.push("tex");
}
if document
.tex_files
.iter()
.any(|tf| tf.path.to_lowercase().ends_with(".rnw"))
{
input_formats.push("rnw");
}
if !document.bib_files.is_empty() {
input_formats.push("bib");
}
if !document.rmd_files.is_empty() {
input_formats.push("rmd");
}
input_formats.sort_unstable();
skipped.push(SkippedRule {
rule_id: rule_id.to_string(),
reason: format!(
"format mismatch (rule formats={}; inputs={})",
python_list_repr(&["rnw", "tex"]),
python_list_repr(&input_formats),
),
});
continue;
}
let mut rule_violations = Vec::new();
if entry.scans_rmd_prose {
for tf in document.all_tex_like_docs() {
rule_violations.extend((entry.check)(
&tf.path,
&tf.parsed,
config.code_width,
));
}
} else {
for tf in &document.tex_files {
rule_violations.extend((entry.check)(
&tf.path,
&tf.parsed,
config.code_width,
));
}
}
run_one(rule_id, rule_violations);
}
RuleAction::Bib(entry) => {
if !has_any_file {
continue;
}
let mut rule_violations = Vec::new();
for bf in &document.bib_files {
rule_violations.extend((entry.check)(
&bf.path,
&bf.source_chars,
&bf.library,
&tex_like,
&tex_file_line_indexes,
config.doi_resolver.as_deref(),
));
}
run_one(rule_id, rule_violations);
}
RuleAction::TexGlobal(entry) => {
if !has_any_file {
continue;
}
let fragments: Vec<(&str, &ParsedTex)> = document
.all_tex_like_docs()
.map(|tf| (tf.path.as_str(), &tf.parsed))
.collect();
let rule_violations = (entry.check)(&fragments);
run_one(rule_id, rule_violations);
}
}
}
let has_any_file = !document.tex_files.is_empty()
|| !document.bib_files.is_empty()
|| !document.rmd_files.is_empty();
if has_any_file {
let (cycles, missing) = project_extra.unwrap_or_default();
if !config.ignore_rules.contains("JSS-PROJECT-001") {
run_one("JSS-PROJECT-001", cycles);
}
if !config.ignore_rules.contains("JSS-PROJECT-002") {
run_one("JSS-PROJECT-002", missing);
}
}
let mut summaries: Vec<CategorySummary> = Vec::new();
for &category_id in catalogue::categories() {
let applied = applied_by_category.get(category_id).copied().unwrap_or(0);
let passed = passed_by_category.get(category_id).copied().unwrap_or(0);
let violations = violations_by_category
.remove(category_id)
.unwrap_or_default();
summaries.push(CategorySummary::build(
category_id.to_string(),
category_title(category_id).to_string(),
applied,
passed,
violations,
));
}
let parse_errors: Vec<Violation> = document
.tex_files
.iter()
.flat_map(|t| t.violations.iter().cloned())
.chain(
document
.bib_files
.iter()
.flat_map(|b| b.violations.iter().cloned()),
)
.chain(
document
.rmd_files
.iter()
.flat_map(|r| r.violations.iter().cloned()),
)
.collect();
if !parse_errors.is_empty() {
summaries.push(CategorySummary {
category_id: "parse".to_string(),
title: "Parse errors".to_string(),
status: crate::report::CategoryStatus::Fail,
rules_applied: 0,
rules_passed: 0,
violations: parse_errors,
});
}
let ratable: Vec<&CategorySummary> = summaries
.iter()
.filter(|s| s.status != crate::report::CategoryStatus::Skipped && s.category_id != "parse")
.collect();
let compliance_percentage = if ratable.is_empty() {
None
} else {
let passed = ratable
.iter()
.filter(|s| s.status == crate::report::CategoryStatus::Pass)
.count();
Some(python_round1(100.0 * passed as f64 / ratable.len() as f64))
};
let mut all_violations: Vec<Violation> = summaries
.iter()
.flat_map(|s| s.violations.iter().cloned())
.collect();
crate::report::sort_violations(&mut all_violations);
ComplianceReport {
tool_version: env!("CARGO_PKG_VERSION").to_string(),
journal_id: config.journal.clone(),
violations: all_violations,
categories: summaries,
compliance_percentage,
skipped_rules: skipped,
}
}