Setting the file. One moment.
Scan A11Y Review · Wix App · wix/skills · Skills Docs
ContentsBack to the top of the page function groupFindings
— line 225
This file
Number 1.81
Position 81 of 81
Type JavaScript
Size 14 KB
Lines 373 scripts/ scan-a11y-review.cjs
JavaScript · 373 lines · 14 KB
16 * could not run or could not load the component (never treat 2 as clean).
17 * Test hook: `A11Y_SKIP_RENDER=1` skips the render audit; the review is then
18 * inconclusive (exit 2) even with no findings.
19 */
20
21 const fs = require ( 'fs' );
22 const path = require ( 'path' );
23 const { spawnSync } = require ( 'child_process' );
24
25 const ROOT = process. cwd ();
26 const RENDER_SCRIPT = path. join (__dirname, 'scan-a11y-render.cjs' );
27 const RENDER_TIMEOUT_MS = Number (process.env. A11Y_RENDER_TIMEOUT_MS ) || 20_000 ;
28 const MAX_LOCATIONS_PER_GROUP = 5 ;
29 const SEVERITY_RANK = { high: 0 , medium: 1 , low: 2 };
30 const CONFIDENCE_RANK = { high: 0 , medium: 1 , low: 2 , unknown: 3 };
31
32 // ─────────────────────────────────────────────────────────────────────────────
33 // Arguments and discovery
34 // ─────────────────────────────────────────────────────────────────────────────
35
36 function parseArgs ( argv ) {
37 const options = { targets: [], render: process.env. A11Y_SKIP_RENDER !== '1' };
38 for ( const arg of argv) {
39 if (arg. startsWith ( '--' )) throw new Error ( `Unknown option ${ arg }` );
40 options.targets. push (arg);
41 }
42 return options;
43 }
44
45 const isSource = ( name ) =>
46 / \. (tsx | jsx) $ / . test (name) && ! / \. (stories | test | spec) \. [jt] sx $ / . test (name);
47
48 function listSources ( dir , acc = []) {
49 for ( const entry of fs. readdirSync (dir, { withFileTypes: true })) {
50 const full = path. join (dir, entry.name);
51 if (entry. isDirectory ()) {
52 if (entry.name !== 'node_modules' ) listSources (full, acc);
53 } else if (entry. isFile () && isSource (entry.name)) acc. push (full);
54 }
55 return acc;
56 }
57
58 function isComponentDir ( dir ) {
59 if ( ! fs. existsSync (dir) || ! fs. statSync (dir). isDirectory ()) return false ;
60 const names = fs. readdirSync (dir);
61 return names. includes ( 'component.tsx' ) || names. some (( name ) => name. endsWith ( '.props.ts' ));
62 }
63
64 function findComponentDir ( file ) {
65 let dir = path. dirname (file);
66 for ( let depth = 0 ; depth < 6 && ! isComponentDir (dir); depth ++ ) {
67 const parent = path. dirname (dir);
68 if (parent === dir) return path. dirname (file);
69 dir = parent;
70 }
71 return isComponentDir (dir) ? dir : path. dirname (file);
72 }
73
74 /** Expand targets (component folders, a components root, or files) into files and component folders. */
75 function discoverFiles ( targets ) {
76 const files = new Set ();
77 const componentDirs = new Set ();
78 for ( const target of targets) {
79 const resolved = path. resolve (target);
80 if ( ! fs. existsSync (resolved)) throw new Error ( `Path not found: ${ target }` );
81 if (fs. statSync (resolved). isDirectory ()) {
82 if ( isComponentDir (resolved)) componentDirs. add (resolved);
83 else {
84 for ( const entry of fs. readdirSync (resolved, { withFileTypes: true })) {
85 const child = path. join (resolved, entry.name);
86 if (entry. isDirectory () && isComponentDir (child)) componentDirs. add (child);
87 }
88 }
89 for ( const file of listSources (resolved)) files. add (file);
90 } else {
91 files. add (resolved);
92 componentDirs. add ( findComponentDir (resolved));
93 }
94 }
95 return { files: [ ... files]. toSorted (), componentDirs: [ ... componentDirs]. toSorted () };
96 }
97
98 // ─────────────────────────────────────────────────────────────────────────────
99 // Scanners
100 // ─────────────────────────────────────────────────────────────────────────────
101
102 async function runScanner ( name , load ) {
103 const started = Date. now ();
104 try {
105 const value = await load ();
106 const parseErrors = (value.meta && value.meta.parseErrors) || [];
107 if (parseErrors. length ) {
108 throw new Error (parseErrors. map (( e ) => `${ e . file }: ${ e . message }` ). join ( '; ' ));
109 }
110 return { ok: true , ms: Date. now () - started, value };
111 } catch (error) {
112 const message = error && error.message ? error.message : String (error);
113 return { ok: false , ms: Date. now () - started, error: `${ name }: ${ message }` };
114 }
115 }
116
117 function eslintFindings ( report ) {
118 return report.findings. map (( f ) => ({
119 rule: f.rule,
120 source: 'eslint' ,
121 severity: 'high' ,
122 confidence: 'high' ,
123 message: f.message,
124 at: `${ f . file }:${ f . line }:${ f . column }` ,
125 }));
126 }
127
128 function semanticFindings ( report ) {
129 return report.findings. map (( f ) => {
130 const contract = f.sourceKind === 'contract' ;
131 const severity =
132 contract || f.confidence === 'high' ? 'high' : f.confidence === 'medium' ? 'medium' : 'low' ;
133 return {
134 rule: f.rule,
135 source: contract ? 'contract' : 'semantic' ,
136 severity,
137 confidence: f.confidence,
138 message: f.message,
139 detail: contract ? null : f.evidence,
140 at: `${ f . file }:${ f . line }:${ f . column }` ,
141 };
142 });
143 }
144
145 function runRenderChild ( componentDir ) {
146 const relativeDir = path. relative ( ROOT , componentDir) || '.' ;
147 const started = Date. now ();
148 const result = spawnSync (process.execPath, [ RENDER_SCRIPT , componentDir], {
149 cwd: ROOT ,
150 encoding: 'utf8' ,
151 timeout: RENDER_TIMEOUT_MS ,
152 killSignal: 'SIGKILL' ,
153 maxBuffer: 8 * 1024 * 1024 ,
154 env: { ... process.env, NODE_ENV: process.env. NODE_ENV || 'development' },
155 });
156 const base = { componentDir: relativeDir, ms: Date. now () - started };
157
158 if (result.error && result.error.code === 'ETIMEDOUT' ) {
159 return {
160 ... base,
161 ok: false ,
162 reason: 'timeout' ,
163 findings: [
164 {
165 rule: 'render-timeout' ,
166 source: 'render' ,
167 severity: 'high' ,
168 confidence: 'high' ,
169 message: `Rendering ${ relativeDir } did not finish within ${ RENDER_TIMEOUT_MS } ms. Rerun once; if it repeats, the first render never settles.` ,
170 },
171 ],
172 };
173 }
174
175 let report = null ;
176 try {
177 const lastLine = String (result.stdout || '' )
178 . trim ()
179 . split ( ' \n ' )
180 . filter (Boolean)
181 . pop ();
182 report = lastLine ? JSON . parse (lastLine) : null ;
183 } catch {
184 report = null ;
185 }
186 if ( ! report) {
187 const error = truncate (result.stderr || `exit ${ result . status }` , 400 );
188 return { ... base, ok: false , reason: 'crash' , error, findings: [] };
189 }
190 if ( ! report.ok) {
191 return {
192 ... base,
193 ok: false ,
194 reason: report.reason || 'error' ,
195 error: report.error || null ,
196 findings: [],
197 };
198 }
199 return {
200 ... base,
201 ok: true ,
202 entry: report.entry,
203 preview: report.preview,
204 ssr: report.ssr,
205 axeRules: report.axe ? report.axe.rulesRun : null ,
206 notChecked: report.notChecked || [],
207 findings: report.findings. map (( f ) => ({
208 ... f,
209 source: 'render' ,
210 at: f.target ? `${ relativeDir } ${ f . target }` : relativeDir,
211 })),
212 };
213 }
214
215 // ─────────────────────────────────────────────────────────────────────────────
216 // Report
217 // ─────────────────────────────────────────────────────────────────────────────
218
219 function truncate ( text , length ) {
220 const compact = String (text || '' ). replace ( / \s + / g , ' ' );
221 return compact. length > length ? `${ compact . slice ( 0 , length - 1 ) }…` : compact;
222 }
223
224 /** One entry per (source, rule, message); repeated nodes become a count plus a few locations. */
225 function groupFindings ( findings , { capPerGroup = MAX_LOCATIONS_PER_GROUP } = {}) {
226 const groups = new Map ();
227 for ( const f of findings) {
228 const severity = f.severity in SEVERITY_RANK ? f.severity : 'medium' ;
229 const confidence = f.confidence in CONFIDENCE_RANK ? f.confidence : 'medium' ;
230 const key = `${ f . source }|${ f . rule }|${ f . message }` ;
231 if ( ! groups. has (key)) {
232 groups. set (key, {
233 rule: f.rule,
234 src: f.source,
235 sev: severity,
236 conf: confidence,
237 n: 0 ,
238 at: [],
239 html: f.html || null ,
240 msg: f.message,
241 detail: f.detail || null ,
242 helpUrl: f.helpUrl || null ,
243 });
244 }
245 const group = groups. get (key);
246 group.n ++ ;
247 if (f.at && group.at. length < capPerGroup && ! group.at. includes (f.at)) group.at. push (f.at);
248 if ( SEVERITY_RANK [severity] < SEVERITY_RANK [group.sev]) group.sev = severity;
249 if ( CONFIDENCE_RANK [confidence] < CONFIDENCE_RANK [group.conf]) group.conf = confidence;
250 }
251 return [ ... groups. values ()]. toSorted (
252 ( a , b ) =>
253 SEVERITY_RANK [a.sev] - SEVERITY_RANK [b.sev] ||
254 CONFIDENCE_RANK [a.conf] - CONFIDENCE_RANK [b.conf] ||
255 b.n - a.n ||
256 a.rule. localeCompare (b.rule),
257 );
258 }
259
260 function renderLabel ( result ) {
261 if (result.ok) {
262 if ( ! result.ssr || ! result.ssr.ok) return `render: ssr FAILED (${ result . entry || '?'})` ;
263 return `render ok (${ result . entry || '?'}, axe ${ result . axeRules ?? '-'})` ;
264 }
265 return `render FAILED (${ result . reason })` ;
266 }
267
268 function buildSummary ( groups , scanners , { renderEnabled = true } = {}) {
269 const counts = { high: 0 , medium: 0 , low: 0 , groups: groups. length , findings: 0 };
270 for ( const group of groups) {
271 counts[group.sev] += group.n;
272 counts.findings += group.n;
273 }
274 // A timeout is itself a finding; any other render failure (missing tooling,
275 // a module the audit cannot load) or a skipped render means the audit did
276 // not run, never clean.
277 const staticFailed = ! scanners.eslint.ok || ! scanners.semantic.ok;
278 const renderBroken =
279 ! renderEnabled || scanners.render. some (( r ) => ! r.ok && r.reason !== 'timeout' );
280 const status =
281 staticFailed || renderBroken ? 'error' : counts.findings > 0 ? 'findings' : 'clean' ;
282 const severities = [ 'high' , 'medium' , 'low' ]
283 . filter (( level ) => counts[level] > 0 )
284 . map (( level ) => `${ counts [ level ] } ${ level }` );
285 const line = [
286 `A11Y ${ counts . groups } groups/${ counts . findings } findings${ severities . length ? ` (${ severities . join ( ', ' ) })` : ''}` ,
287 `eslint ${ scanners . eslint . ok ? 'ok' : 'FAILED'}` ,
288 `semantic ${ scanners . semantic . ok ? 'ok' : 'FAILED'}` ,
289 renderEnabled
290 ? scanners.render. map (renderLabel). join ( ' · ' ) || 'render: no component folder'
291 : 'render SKIPPED (inconclusive)' ,
292 ]. join ( ' · ' );
293 return { status, line, counts };
294 }
295
296 const computeExitCode = ( summary ) =>
297 summary.status === 'error' ? 2 : summary.counts.findings > 0 ? 1 : 0 ;
298
299 async function review ( options ) {
300 const { files , componentDirs } = discoverFiles (options.targets);
301 // A wrong path (a typo, a parent folder) must never read as clean.
302 if (componentDirs. length === 0 ) {
303 throw new Error (
304 `No component folder under ${ options . targets . join ( ', ' ) }: expected component.tsx or *.props.ts.` ,
305 );
306 }
307
308 // ESLint rejects an empty file list; a folder with no JSX still gets its render audit.
309 const eslint = await runScanner ( 'eslint' , () =>
310 files. length ? require ( './scan-a11y-eslint.cjs' ). scan (files) : { findings: [] },
311 );
312 const semantic = await runScanner ( 'semantic' , () =>
313 files. length ? require ( './scan-a11y-code.cjs' ). scan (files) : { findings: [] },
314 );
315 const render = options.render ? componentDirs. map (runRenderChild) : [];
316
317 const findings = [
318 ... (eslint.ok ? eslintFindings (eslint.value) : []),
319 ... (semantic.ok ? semanticFindings (semantic.value) : []),
320 ... render. flatMap (( r ) => r.findings),
321 ];
322 const groups = groupFindings (findings);
323
324 const notChecked = new Set (options.render ? [] : [ 'render audit (skipped)' ]);
325 for ( const r of render) {
326 for ( const item of r.notChecked || []) notChecked. add (item);
327 }
328
329 const scanners = {
330 eslint: { ok: eslint.ok, ms: eslint.ms, error: eslint.error || null },
331 semantic: { ok: semantic.ok, ms: semantic.ms, error: semantic.error || null },
332 render: render. map (({ findings : _findings , notChecked : _notChecked , ... rest }) => rest),
333 };
334 const summary = buildSummary (groups, scanners, { renderEnabled: options.render });
335 return {
336 summary,
337 findings: groups,
338 scanners,
339 files: files. map (( file ) => path. relative ( ROOT , file)),
340 notChecked: [ ... notChecked],
341 exitCode: computeExitCode (summary),
342 };
343 }
344
345 async function main () {
346 let options;
347 try {
348 options = parseArgs (process.argv. slice ( 2 ));
349 if (options.targets. length === 0 ) throw new Error ( 'No component directory or files specified.' );
350 } catch (error) {
351 console. error (
352 `${ error . message } \n Usage: node <SKILL_ROOT>/scripts/scan-a11y-review.cjs <component-dir | files...>` ,
353 );
354 process. exit ( 2 );
355 }
356 try {
357 const report = await review (options);
358 console. log ( JSON . stringify (report));
359 process. exit (report.exitCode);
360 } catch (error) {
361 console. log (
362 JSON . stringify ({
363 summary: { status: 'error' , line: error.message, counts: { findings: 0 } },
364 findings: [],
365 }),
366 );
367 process. exit ( 2 );
368 }
369 }
370
371 module . exports = { review, discoverFiles, groupFindings, buildSummary, computeExitCode };
372
373 if (require.main === module ) main ();