Skip to content

Commit e2d94be

Browse files
voidstackloopclaude
andcommitted
Add a tenth check: required-indicator mismatch
Design (per the /engineering:system-design request): many forms mark required fields visually — a `*` or "(required)" next to the label — as a UX convention. When that visual promise isn't backed by an actual `required`/`aria-required="true"` attribute, the field is silently optional: native validation won't block submission, screen readers won't announce it as required, and a user who skips it (believing the visual cue, since that's the whole point of it) can submit incomplete data with zero error. This happens when a design system's "required" visual styling gets wired up independently of the real validation attribute. check_required_indicator_mismatch resolves each field's own label text, tests it for a required-looking signal (`*` or the word "required"), and flags any match whose field lacks both `required` and `aria-required="true"`. Not what axe-core's label rule checks (that's about a label existing, not about a required-looking one being honored), and not covered by any of the other nine checks. Always a Fail: the field's own label contradicts its own enforcement, which is objectively verifiable rather than a heuristic guess. fixtures/required-indicator-mismatch-form.html covers all three cases in one fixture: "Full name *" (the bug — no required attribute), "Email *" (the correctly-wired case, proving no false positive), and "Comments" (no required-looking label at all, irrelevant either way). Verified both directions by mutation: forced isRequired to always be true (confirmed the Fail test caught the resulting false negative), then forced it to always be false (confirmed the "Email must not be flagged" assertion caught the resulting false positive), before restoring the real logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent cc635bb commit e2d94be

5 files changed

Lines changed: 151 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ project doesn't have a release yet, so everything below is grouped under
3333
no concept of submission semantics) and not covered by any other
3434
check. Always a `Fail`: unlike Bot protection, this is a real defect
3535
in the form itself.
36+
- A tenth check, **Required-indicator mismatch**: flags a field whose
37+
own label visually promises it's required (a `*`, or the word
38+
"required") but isn't actually marked `required` or
39+
`aria-required="true"`. The visual promise and the real enforcement
40+
silently disagree — native validation and screen readers both treat
41+
the field as optional — so a user who skips it can submit incomplete
42+
data with no error at all. Not what axe-core's label rule checks
43+
(that's about a label *existing*, not about a required-looking one
44+
being honored), and not covered by any other check. Always a `Fail`:
45+
the field's own label contradicts its own enforcement.
3646
- **Optional LLM semantic checks** (`--llm` / `llm:` config): two
3747
provider-agnostic checks — "Error wording (LLM)" and
3848
"Instructions (LLM)" — that score the clarity of validation error

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ your own use.
9595
for a repeated key, so two different pieces of information submitted
9696
under one name means one vanishes with no client-side signal at all —
9797
not an accessibility issue, and not covered by any other check.
98+
- **Required-indicator mismatch** — flags a field whose own label
99+
visually promises it's required (a `*`, or the word "required") but
100+
isn't actually marked `required` or `aria-required`. The visual
101+
promise and the real enforcement silently disagree, so a user who
102+
skips the field can submit incomplete data with no error at all — not
103+
what axe-core's label rule checks (that's about a label *existing*,
104+
not about a required-looking one being honored).
98105

99106
## Commands
100107

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head><meta charset="utf-8"><title>Required Indicator Mismatch</title></head>
4+
<body>
5+
<!-- "Full name *" looks required to any user, but the required
6+
attribute was never wired up — native validation and screen
7+
readers both treat it as optional. "Email *" is the correctly-
8+
wired case (same visual promise, backed by the real attribute),
9+
proving the check doesn't false-positive on a form that got it
10+
right. "Comments" has no required-looking label and no
11+
required attribute — irrelevant, should not be flagged either. -->
12+
<form>
13+
<label for="fullname">Full name *</label>
14+
<input id="fullname" name="fullname" type="text">
15+
16+
<label for="email">Email *</label>
17+
<input id="email" name="email" type="email" required>
18+
19+
<label for="comments">Comments</label>
20+
<textarea id="comments" name="comments"></textarea>
21+
22+
<button type="submit">Submit</button>
23+
</form>
24+
</body>
25+
</html>

src/checks.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,67 @@ pub async fn check_duplicate_names(page: &Page) -> Result<CheckResult> {
980980
))
981981
}
982982

983+
/// Flags a field whose own label visually promises it's required (a `*`,
984+
/// or the word "required") but that isn't actually marked `required` or
985+
/// `aria-required="true"`. A form's "required" styling is often wired up
986+
/// independently of the real validation attribute — the visual promise
987+
/// and the actual enforcement silently disagree, so a user who skips the
988+
/// field (it looks optional to native validation and to a screen reader)
989+
/// can submit incomplete data with no error at all. Not what axe-core's
990+
/// label rule checks (that's about a label *existing*, not about a
991+
/// required-looking one being honored), and not covered by any other
992+
/// check. Always a `Fail`: the field's own label contradicts its own
993+
/// enforcement — this is objectively verifiable, not a heuristic guess.
994+
pub async fn check_required_indicator_mismatch(page: &Page) -> Result<CheckResult> {
995+
let mismatches: Vec<String> = page
996+
.evaluate(format!(
997+
r#"(() => {{
998+
const f = {TARGET_FORM_JS};
999+
const fields = f ? ({DEEP_QUERY_JS})(f, 'input, select, textarea') : [];
1000+
const labelOf = (el) => {{
1001+
const byFor = el.id
1002+
&& ({DEEP_QUERY_JS})(document, 'label').find((l) => l.htmlFor === el.id)?.textContent;
1003+
const byWrap = el.closest('label')?.textContent;
1004+
return (byFor || byWrap || '').trim();
1005+
}};
1006+
const looksRequired = (text) => /\*|\brequired\b/i.test(text);
1007+
const mismatches = [];
1008+
for (const el of fields) {{
1009+
if (el.disabled || el.offsetParent === null) continue;
1010+
const label = labelOf(el);
1011+
if (!label || !looksRequired(label)) continue;
1012+
const isRequired = el.required || el.getAttribute('aria-required')?.toLowerCase() === 'true';
1013+
if (!isRequired) mismatches.push(label.slice(0, 60));
1014+
}}
1015+
return mismatches;
1016+
}})()"#
1017+
))
1018+
.await?
1019+
.into_value()?;
1020+
1021+
let status = if mismatches.is_empty() {
1022+
Status::Pass
1023+
} else {
1024+
Status::Fail
1025+
};
1026+
Ok(result(
1027+
"Required-indicator mismatch",
1028+
status,
1029+
if mismatches.is_empty() {
1030+
"Every field whose label looks required (\"*\" or \"required\") is actually marked \
1031+
required or aria-required."
1032+
.to_string()
1033+
} else {
1034+
format!(
1035+
"{} field(s) look required by their own label but aren't marked required or \
1036+
aria-required — native validation and screen readers treat them as optional: {}.",
1037+
mismatches.len(),
1038+
mismatches.join("; ")
1039+
)
1040+
},
1041+
))
1042+
}
1043+
9831044
/// Detects a bot-protection/CAPTCHA challenge (reCAPTCHA, hCaptcha,
9841045
/// Cloudflare Turnstile, or a generic "verify you're human" interstitial)
9851046
/// on the page. Never a Fail: none of this is evidence the *form* is
@@ -1224,6 +1285,14 @@ pub async fn run_all_with(page: &Page, opts: &RunOptions) -> Vec<CheckResult> {
12241285
check_duplicate_names(page),
12251286
)
12261287
.await,
1288+
run_safely(
1289+
page,
1290+
"Required-indicator mismatch",
1291+
check_timeout,
1292+
capture,
1293+
check_required_indicator_mismatch(page),
1294+
)
1295+
.await,
12271296
submission,
12281297
run_safely(
12291298
page,

tests/checks_test.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,3 +814,43 @@ async fn form_with_no_duplicate_names_passes() {
814814
.expect("check_duplicate_names");
815815
assert_eq!(result.status, checks::Status::Pass, "got: {result:?}");
816816
}
817+
818+
#[tokio::test]
819+
async fn required_looking_label_with_no_required_attribute_is_a_fail() {
820+
// fixtures/required-indicator-mismatch-form.html's "Full name *"
821+
// looks required to any user but was never wired up with the real
822+
// attribute — native validation and screen readers both treat it as
823+
// optional. "Email *" is the correctly-wired case (same visual
824+
// promise, backed by `required`), proving no false positive on a
825+
// form that got it right; "Comments" has neither a required-looking
826+
// label nor the attribute, and is irrelevant to this check either way.
827+
let (_browser, page, _handle) = open_fixture("required-indicator-mismatch-form.html").await;
828+
let result = checks::check_required_indicator_mismatch(&page)
829+
.await
830+
.expect("check_required_indicator_mismatch");
831+
assert_eq!(result.status, checks::Status::Fail, "got: {result:?}");
832+
assert!(
833+
result.detail.contains("Full name"),
834+
"got: {}",
835+
result.detail
836+
);
837+
assert!(
838+
!result.detail.contains("Email"),
839+
"the correctly-wired field must not be flagged, got: {}",
840+
result.detail
841+
);
842+
assert!(
843+
!result.detail.contains("Comments"),
844+
"a field with no required-looking label is irrelevant to this check, got: {}",
845+
result.detail
846+
);
847+
}
848+
849+
#[tokio::test]
850+
async fn form_with_no_required_looking_labels_passes_the_mismatch_check() {
851+
let (_browser, page, _handle) = open_fixture("test-form.html").await;
852+
let result = checks::check_required_indicator_mismatch(&page)
853+
.await
854+
.expect("check_required_indicator_mismatch");
855+
assert_eq!(result.status, checks::Status::Pass, "got: {result:?}");
856+
}

0 commit comments

Comments
 (0)