1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
//! Risikobewertung des normalisierten Reports.
//!
//! Leitet aus den Befunden, ihren Vorkommen, den interaktiven Befunden und
//! der Screenreader-Zusammenfassung die `RiskAssessment` ab: Stufe, Score,
//! auslösende Schwelle, rechtlich relevante und blockierende Befunde.
//! Aufgerufen nur von `normalize`.
use crate::audit::normalized::{
InteractiveFinding, NormalizedFinding, RiskAssessment, RiskLevel, SeverityCounts,
};
use crate::taxonomy::Severity;
pub(super) fn compute_risk_assessment(
findings: &[NormalizedFinding],
occurrence_counts: &SeverityCounts,
interactive_findings: &[InteractiveFinding],
screen_reader: Option<&crate::screen_reader::ScreenReaderSummary>,
score: u32,
overall_score: u32,
) -> RiskAssessment {
// Risk thresholds reflect total affected elements (occurrence_counts),
// not the number of distinct rules (severity_counts).
let critical_issues = occurrence_counts.critical;
let high_issues = occurrence_counts.high;
// Legal flags: count distinct WCAG Level A rules with High/Critical severity.
// Per-occurrence counting would inflate the number (e.g. 1000 images without
// alt text is one rule violation, not 1000 legal flags).
let mut legal_flags = findings.iter().filter(|f| f.is_legal_flag()).count();
// The screen-reader audit can detect journey-level BFSG barriers the static
// WCAG engine misses, so the main report and the screen-reader sidecar should
// not disagree on legal status (#484). Only *confirmed* Level-A blockers raise
// the legal flag — the SR "high" severity findings (empty interactive elements
// 4.1.2, unlabeled form fields 3.3.2). Medium/low heuristics (generic link
// text, tab-stop count, heading order) are deliberately excluded so soft
// signals do not inflate legal risk on otherwise well-maintained sites. The
// consent-wall guard (#483) keeps incomplete audits from contributing.
if let Some(sr) = screen_reader {
let consent_wall = matches!(
sr.summary.audit_quality,
crate::screen_reader::SrAuditQuality::ConsentWallSuspected
);
if sr.bfsg_compliance.verdict == crate::screen_reader::BfsgVerdict::NonCompliant
&& sr.count_severity("high") > 0
&& !consent_wall
{
legal_flags = legal_flags.max(1);
}
}
// Blocking issues: controls assistive technology cannot identify or a
// keyboard cannot operate — a missing name or role (4.1.2) or anything
// under 2.1.1. Other 4.1.2 findings (duplicate IDs, nameless dialogs,
// redundant roles, attribute values) are real defects but block no
// control; counting them turned gov.uk's two empty status regions into
// "controls not operable with assistive technology".
// Only Medium+ severity — Low findings (e.g. accordion advisory) are not blockers.
// Counted per element: on sachsen-anhalt.de three name rules reported the
// same menu element, which read as "3 Bedienelemente". Occurrences are
// identified by node, or by selector where the node is a placeholder;
// occurrences a finding counts but does not list stay counted as distinct.
let mut blocking_elements = std::collections::HashSet::new();
let mut blocking_unlisted = 0usize;
for f in findings.iter().filter(|f| {
(f.wcag_criterion == "2.1.1"
|| (f.wcag_criterion == "4.1.2"
&& crate::taxonomy::is_missing_name_or_role(&f.rule_id)))
&& matches!(
f.severity,
Severity::Medium | Severity::High | Severity::Critical
)
}) {
for o in &f.occurrences {
let key = match (o.node_id.as_str(), &o.selector) {
("document", Some(selector)) => format!("sel:{selector}"),
(node, _) => format!("node:{node}"),
};
blocking_elements.insert(key);
}
blocking_unlisted += f.occurrence_count.saturating_sub(f.occurrences.len());
}
let blocking_issues = blocking_elements.len() + blocking_unlisted;
let interactive_critical_issues = interactive_findings
.iter()
.filter(|f| f.severity == Severity::Critical)
.count();
let interactive_high_issues = interactive_findings
.iter()
.filter(|f| f.severity == Severity::High)
.count();
// Screen-reader audit issues are heuristic quality signals (reading order,
// landmark/heading quality). They contribute to the risk score but never to
// the legally-relevant severity_counts/legal_flags (#411). SR emits no
// Critical severity — only low/medium/high strings.
let (sr_high_issues, sr_medium_issues) = screen_reader
.map(|sr| (sr.count_severity("high"), sr.count_severity("medium")))
.unwrap_or((0, 0));
let risk_score = (legal_flags as u32 * 20
+ critical_issues as u32 * 10
+ high_issues as u32 * 3
+ blocking_issues as u32 * 2
+ interactive_critical_issues as u32 * 10
+ interactive_high_issues as u32 * 5
+ sr_high_issues as u32 * 3
+ sr_medium_issues as u32)
.min(100);
// Risk level — explicit precedence; legal_flags and blocking_issues both
// raise the floor even when critical_issues is zero (see issue #250).
//
// "Critical" is reserved for *systemic* legal exposure: breadth (≥3 distinct
// WCAG Level A rules with High/Critical severity) OR volume (≥5 critical
// occurrences). The breadth path must NOT also require a critical occurrence
// — legal_flags already counts High-severity Level A rules, so gating it on
// `critical_issues > 0` made a site with 4 High-severity legal barriers rank
// *below* one with a single flag but 5+ critical occurrences (#457). A single
// isolated legal flag with a few critical occurrences stays High (#250).
let level = if legal_flags >= 3 || critical_issues >= 5 {
RiskLevel::Critical
} else if (legal_flags > 0 && critical_issues > 0)
|| critical_issues >= 3
|| blocking_issues >= 5
|| risk_score >= 80
{
RiskLevel::High
} else if (high_issues >= 3 && score < 80)
|| critical_issues >= 1
|| legal_flags > 0
|| blocking_issues >= 1
|| interactive_critical_issues > 0
|| interactive_high_issues > 0
|| score <= 20
{
RiskLevel::Medium
} else {
RiskLevel::Low
};
let plural = |n: usize, singular: &str, plural: &str| -> String {
if n == 1 {
format!("{} {}", n, singular)
} else {
format!("{} {}", n, plural)
}
};
let summary = match level {
RiskLevel::Critical => {
// If the overall score is high (≥ 80, i.e. Grade A or B), the
// Critical risk level alongside a strong grade looks
// contradictory. Surface the contrast explicitly so the report
// explains why both signals can hold at once. See issue #237.
let prefix = if overall_score >= 80 {
"Critical risk despite high overall score"
} else {
"Critical risk"
};
if legal_flags >= 3 {
// Breadth-driven: multiple distinct legally-relevant barriers.
format!(
"{}: {} with legal relevance (BFSG). {}.",
prefix,
plural(
legal_flags,
"WCAG Level A violation",
"WCAG Level A violations"
),
plural(
blocking_issues,
"blocker on interactive elements",
"blockers on interactive elements"
)
)
} else {
// Volume-driven: a high number of critical occurrences.
format!(
"{}: {} on interactive elements and content.",
prefix,
plural(critical_issues, "critical violation", "critical violations")
)
}
}
RiskLevel::High => format!(
"High risk: {} and {}. Users are actively excluded.",
plural(critical_issues, "critical issue", "critical issues"),
plural(high_issues, "serious issue", "serious issues")
),
RiskLevel::Medium => {
if legal_flags > 0 {
format!(
"Medium risk: {} with legal relevance (BFSG){}.",
plural(
legal_flags,
"WCAG Level A violation",
"WCAG Level A violations"
),
if blocking_issues > 0 {
format!(
", {} on interactive elements",
plural(blocking_issues, "blocker", "blockers")
)
} else {
String::new()
}
)
} else if blocking_issues > 0 {
format!(
"Medium risk: {} detected on interactive elements. \
Restrictions for certain user groups.",
plural(blocking_issues, "blocker", "blockers")
)
} else if interactive_critical_issues > 0 {
format!(
"Medium risk: {} from interactive keyboard and state-transition tests.",
plural(
interactive_critical_issues,
"critical finding",
"critical findings"
)
)
} else if interactive_high_issues > 0 {
format!(
"Medium risk: {} from interactive keyboard and state-transition tests.",
plural(
interactive_high_issues,
"serious finding",
"serious findings"
)
)
} else {
format!(
"Medium risk: {} detected. Restrictions for certain user groups.",
plural(
high_issues + critical_issues,
"serious issue",
"serious issues"
)
)
}
}
RiskLevel::Low => {
let notable = interactive_high_issues + interactive_critical_issues;
if notable > 0 {
format!(
"Low risk: No critical violations — keyboard journey contains {}, manual review recommended.",
plural(notable, "notable finding", "notable findings")
)
} else {
"Low risk: No critical violations — improvement potential exists.".to_string()
}
}
};
let (threshold, driven_by) = match level {
RiskLevel::Critical => (
60u32,
// Attribute to the condition that actually triggered Critical:
// breadth of legal exposure (≥3 distinct Level A rules) vs. a high
// volume of critical occurrences. A single flag that rode in on
// volume must not be labelled "Legal Compliance" (#457).
if legal_flags >= 3 {
"Legal Compliance"
} else {
"Accessibility"
}
.to_string(),
),
RiskLevel::High => (30u32, "Accessibility".to_string()),
RiskLevel::Medium => (
10u32,
if interactive_critical_issues > 0 {
"Accessibility Journey"
} else if score <= 20 {
"Score"
} else {
"Accessibility"
}
.to_string(),
),
RiskLevel::Low => (0u32, String::new()),
};
RiskAssessment {
level,
score: risk_score,
threshold,
driven_by,
critical_issues,
high_issues,
legal_flags,
blocking_issues,
interactive_critical_issues,
interactive_high_issues,
summary,
}
}
#[cfg(test)]
mod blocking_tests {
use crate::taxonomy::is_missing_name_or_role;
#[test]
fn only_missing_names_and_roles_block() {
for id in [
"a11y.interactive_name.missing",
"a11y.command_name.missing",
"a11y.control_name.missing",
"a11y.input_field_name.missing",
"a11y.toggle_field_name.missing",
"a11y.name_role.missing",
] {
assert!(is_missing_name_or_role(id), "{id}");
}
for id in [
"a11y.parsing.invalid",
"a11y.dialog_missing_name.invalid",
"a11y.redundant_role.invalid",
"a11y.aria_valid_attr_value.invalid",
"a11y.combobox_options.missing",
"a11y.description_duplicates_name.redundant",
"a11y.title_only_description.weak",
] {
assert!(!is_missing_name_or_role(id), "{id}");
}
}
}