nx_a11y_check.nx source
↩ module page · 329 lines · 11626 B
1// nx_a11y_check.nx -- Layer-3 WCAG 2.2 AA accessibility grader.
2//
3// Enforces the ADA-compliance cardinal
4// feedback-ada-compliance-equal-experiences (2026-05-15):
5//
6// "the adc [ADA] is another thing we want compliance on so disabled
7// people have equal experiences"
8//
9// Scans HTML source (either a standalone .html file or the HTML
10// payload string inside a NishiLang web file) for the most
11// frequently-violated WCAG 2.2 success criteria. This first cut
12// covers three high-impact criteria that are reliably detectable via
13// lexical scan:
14//
15// 1.1.1 Non-text Content -- every <img> must carry an alt= attr
16// 1.4.4 Resize Text -- font-size must not use px
17// 2.4.4 Link Purpose -- avoid vague link text ("click here",
18// "here", "read more", "more")
19//
20// Future criteria (queued for IR + state-machine scan):
21// 1.4.3 contrast (needs CSS resolution)
22// 2.1.1 keyboard nav (needs JS + handler scan)
23// 3.3.2 form labels (needs cross-element pairing)
24// 4.1.2 name/role/value (needs ARIA-aware scan)
25//
26// Verdict shape mirrors nx_license_check: per-criterion violation
27// count, per-criterion sealed verdict (WIN/LOSE/UNMEASURED), and an
28// overall WIN/LOSE that the polyangulated grader can consume as an
29// 8th perspective.
30//
31// license_tier: ORIGINAL
32// genealogy_id: international-research-sources/w3c/wcag22_aa
33//
34// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml)
35// intended_use: "Layer-3 WCAG 2.2 AA accessibility grader --
36// enforces ADA-compliance cardinal so disabled
37// users have equal experiences across substrate-
38// emitted HTML / web frontends."
39// sil_target: SIL2 (accessibility-as-safety: ADA non-
40// compliance can prevent disabled
41// users from operating safety-critical
42// systems; classifies as injury under
43// the cardinal interpretation)
44// asil_target: QM
45// dal_target: NONE
46// iec_62304_class: NONE
47// evidence: [no_floating_point, sealed_NX_A11Y_VERDICT,
48// sealed_NX_WCAG_criterion_enum_complete_3,
49// per_criterion_violation_count_tracked,
50// W3C_WCAG_2_2_AA_open_spec_basis]
51// hazard_register: [bug-tape-WCAG_1_1_1_img_no_alt,
52// bug-tape-WCAG_1_4_4_px_font_size,
53// bug-tape-WCAG_2_4_4_vague_link_text,
54// bug-tape-lexical-scan-misses-DOM-context]
55// residual_risk: "First-cut covers 3 of 50+ WCAG 2.2 AA
56// success criteria. Contrast (1.4.3),
57// keyboard nav (2.1.1), form labels (3.3.2),
58// ARIA (4.1.2) require IR + state-machine scan
59// which is queued. GRADER reports UNMEASURED
60// for those, never silently PASS."
61// verdict: NOT_YET_EVALUATED
62
63import "nx_syscalls.nx"
64import "nx_runtime.nx"
65import "nx_types.nx"
66import "nx_tier.nx"
67
68// ---- Sealed verdict (mirror of nx_license_verdict) ------------------
69
70const NX_A11Y_VERDICT_UNMEASURED: nx_int = 0
71const NX_A11Y_VERDICT_WIN: nx_int = 1
72const NX_A11Y_VERDICT_LOSE: nx_int = 2
73const NX_A11Y_VERDICT_TIE: nx_int = 3
74
75// ---- Sealed enum: WCAG 2.2 success criterion ------------------------
76
77const NX_WCAG_1_1_1_TEXT_ALT: nx_int = 0
78const NX_WCAG_1_4_4_RESIZE: nx_int = 1
79const NX_WCAG_2_4_4_LINK_PURPOSE: nx_int = 2
80const NX_WCAG_CRIT_N: nx_int = 3
81
82func nx_wcag_criterion_is_valid(c: nx_int) -> nx_int {
83 if c < 0 { return 0 }
84 if c >= NX_WCAG_CRIT_N { return 0 }
85 return 1
86}
87
88// ---- Per-criterion report struct ------------------------------------
89//
90// Fixed-stride so the polyangulated grader can pack an array.
91
92struct A11yCritReport {
93 criterion: nx_int,
94 violation_count: nx_int,
95 verdict: nx_int,
96}
97
98struct A11yCard {
99 crit_text_alt: A11yCritReport,
100 crit_resize: A11yCritReport,
101 crit_link_purpose: A11yCritReport,
102 n_violations_total: nx_int,
103 n_lose: nx_int,
104 n_win: nx_int,
105 overall_verdict: nx_int,
106}
107
108// ---- Byte helpers (start-of-line not required; HTML scan is global) -
109
110func _a_byte_at(src: *u8, i: nx_int) -> nx_int {
111 let p: *u8 = (src as nx_int + i) as *u8
112 let b: nx_int = p[0] as nx_int
113 return b & 255
114}
115
116func _a_lower(c: nx_int) -> nx_int {
117 if c >= 65 {
118 if c <= 90 { return c + 32 }
119 }
120 return c
121}
122
123func _a_eq_ci(src: *u8, src_len: nx_int, off: nx_int, needle: *u8, needle_len: nx_int) -> nx_int {
124 var k: nx_int = 0
125 if off + needle_len > src_len { return 0 }
126 while k < needle_len {
127 let a: nx_int = _a_lower(_a_byte_at(src, off + k))
128 let b: nx_int = _a_lower(_a_byte_at(needle, k))
129 if a != b { return 0 }
130 k = k + 1
131 }
132 return 1
133}
134
135func _a_is_digit(c: nx_int) -> nx_int {
136 if c < 48 { return 0 }
137 if c > 57 { return 0 }
138 return 1
139}
140
141// ---- WCAG 1.1.1: <img> without alt= ---------------------------------
142//
143// Scan strategy: for each `<img` occurrence, look forward to the next
144// `>` and check that an `alt=` attribute appears within the tag.
145
146func _count_img_without_alt(src: *u8, src_len: nx_int) -> nx_int {
147 var i: nx_int = 0
148 var bad: nx_int = 0
149 let img_tag: *u8 = "<img" as *u8
150 let alt_attr: *u8 = "alt=" as *u8
151 while i < src_len - 4 {
152 if _a_eq_ci(src, src_len, i, img_tag, 4) == 1 {
153 // Find tag end (next `>`).
154 var j: nx_int = i + 4
155 var has_alt: nx_int = 0
156 while j < src_len {
157 let c: nx_int = _a_byte_at(src, j)
158 if c == 62 { j = src_len + 1; }
159 else {
160 if _a_eq_ci(src, src_len, j, alt_attr, 4) == 1 { has_alt = 1 }
161 j = j + 1
162 }
163 }
164 if has_alt == 0 { bad = bad + 1 }
165 i = i + 4
166 } else {
167 i = i + 1
168 }
169 }
170 return bad
171}
172
173// ---- WCAG 1.4.4: font-size in px ------------------------------------
174//
175// Scan for `font-size:` followed by a number and `px`.
176
177func _skip_ws_and_digits(src: *u8, src_len: nx_int, off: nx_int) -> nx_int {
178 var j: nx_int = off
179 var saw_digit: nx_int = 0
180 while j < src_len {
181 let c: nx_int = _a_byte_at(src, j)
182 if c == 32 {
183 j = j + 1
184 } else {
185 if _a_is_digit(c) == 1 {
186 saw_digit = 1
187 j = j + 1
188 } else {
189 if saw_digit == 1 { return j }
190 return -1
191 }
192 }
193 }
194 return -1
195}
196
197func _count_px_font(src: *u8, src_len: nx_int) -> nx_int {
198 var i: nx_int = 0
199 var bad: nx_int = 0
200 let fs_tag: *u8 = "font-size:" as *u8
201 let px_str: *u8 = "px" as *u8
202 while i < src_len - 10 {
203 if _a_eq_ci(src, src_len, i, fs_tag, 10) == 1 {
204 let after: nx_int = _skip_ws_and_digits(src, src_len, i + 10)
205 if after >= 0 {
206 if _a_eq_ci(src, src_len, after, px_str, 2) == 1 {
207 bad = bad + 1
208 }
209 }
210 i = i + 10
211 } else {
212 i = i + 1
213 }
214 }
215 return bad
216}
217
218// ---- WCAG 2.4.4: vague link text ------------------------------------
219//
220// Scan for the literal phrases ">click here<", ">here<", ">read more<",
221// ">more<". Conservative -- catches the obvious offenders, may miss
222// case-variants with whitespace inside the > < bracket pair (left for
223// a future refinement).
224
225func _count_vague_link(src: *u8, src_len: nx_int) -> nx_int {
226 var i: nx_int = 0
227 var bad: nx_int = 0
228 let p1: *u8 = ">click here<" as *u8
229 let p2: *u8 = ">here<" as *u8
230 let p3: *u8 = ">read more<" as *u8
231 let p4: *u8 = ">more<" as *u8
232 while i < src_len {
233 if _a_eq_ci(src, src_len, i, p1, 12) == 1 { bad = bad + 1; i = i + 12 }
234 else {
235 if _a_eq_ci(src, src_len, i, p2, 6) == 1 { bad = bad + 1; i = i + 6 }
236 else {
237 if _a_eq_ci(src, src_len, i, p3, 11) == 1 { bad = bad + 1; i = i + 11 }
238 else {
239 if _a_eq_ci(src, src_len, i, p4, 6) == 1 { bad = bad + 1; i = i + 6 }
240 else { i = i + 1 }
241 }
242 }
243 }
244 }
245 return bad
246}
247
248// ---- Verdict per criterion -----------------------------------------
249
250func _verdict_from_count(n: nx_int) -> nx_int {
251 if n == 0 { return NX_A11Y_VERDICT_WIN }
252 return NX_A11Y_VERDICT_LOSE
253}
254
255// ---- Scan entry: bytes -> A11yCard ---------------------------------
256
257func nx_a11y_scan(src: *u8, src_len: nx_int, card: *A11yCard) -> nx_int {
258 let n1: nx_int = _count_img_without_alt(src, src_len)
259 let n2: nx_int = _count_px_font(src, src_len)
260 let n3: nx_int = _count_vague_link(src, src_len)
261
262 card.crit_text_alt.criterion = NX_WCAG_1_1_1_TEXT_ALT
263 card.crit_text_alt.violation_count = n1
264 card.crit_text_alt.verdict = _verdict_from_count(n1)
265
266 card.crit_resize.criterion = NX_WCAG_1_4_4_RESIZE
267 card.crit_resize.violation_count = n2
268 card.crit_resize.verdict = _verdict_from_count(n2)
269
270 card.crit_link_purpose.criterion = NX_WCAG_2_4_4_LINK_PURPOSE
271 card.crit_link_purpose.violation_count = n3
272 card.crit_link_purpose.verdict = _verdict_from_count(n3)
273
274 let total: nx_int = n1 + n2 + n3
275 card.n_violations_total = total
276
277 var lose: nx_int = 0
278 var win: nx_int = 0
279 if card.crit_text_alt.verdict == NX_A11Y_VERDICT_LOSE { lose = lose + 1 }
280 if card.crit_resize.verdict == NX_A11Y_VERDICT_LOSE { lose = lose + 1 }
281 if card.crit_link_purpose.verdict == NX_A11Y_VERDICT_LOSE { lose = lose + 1 }
282 if card.crit_text_alt.verdict == NX_A11Y_VERDICT_WIN { win = win + 1 }
283 if card.crit_resize.verdict == NX_A11Y_VERDICT_WIN { win = win + 1 }
284 if card.crit_link_purpose.verdict == NX_A11Y_VERDICT_WIN { win = win + 1 }
285 card.n_lose = lose
286 card.n_win = win
287 if lose == 0 {
288 card.overall_verdict = NX_A11Y_VERDICT_WIN
289 } else {
290 card.overall_verdict = NX_A11Y_VERDICT_LOSE
291 }
292 return card.overall_verdict
293}
294
295// ---- File-level helper ---------------------------------------------
296
297func nx_a11y_scan_file(path: *u8, card: *A11yCard) -> nx_int {
298 let len_raw: *u8 = sys_mmap(16)
299 let len_p: *i64 = len_raw as *i64
300 let src: *u8 = sys_read_file(path, len_p)
301 if src == (0 as *u8) {
302 card.n_violations_total = 0
303 card.overall_verdict = NX_A11Y_VERDICT_UNMEASURED
304 return NX_A11Y_VERDICT_UNMEASURED
305 }
306 let src_len: nx_int = len_p[0]
307 return nx_a11y_scan(src, src_len, card)
308}
309
310// ---- Named-improvement string per criterion ------------------------
311
312func nx_a11y_named_improvement(c: nx_int) -> *u8 {
313 if c == NX_WCAG_1_1_1_TEXT_ALT {
314 return "WCAG 1.1.1: every <img> must carry alt= -- generate alt text from scene-graph semantics" as *u8
315 }
316 if c == NX_WCAG_1_4_4_RESIZE {
317 return "WCAG 1.4.4: replace font-size px with rem/em so users can resize text" as *u8
318 }
319 if c == NX_WCAG_2_4_4_LINK_PURPOSE {
320 return "WCAG 2.4.4: replace vague link text (\"click here\" / \"more\") with descriptive text" as *u8
321 }
322 return "" as *u8
323}
324
325func nx_a11y_card_alloc() -> *A11yCard {
326 let raw: *u8 = sys_mmap(256)
327 let c: *A11yCard = raw as *A11yCard
328 return c
329}