@qatonic_innovations/qaios 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -42
- package/dist/GateReview-MDXNA542.js +127 -0
- package/dist/SnapshotReview-7TPWX62T.js +137 -0
- package/dist/a11y/scripts/axe-runner.mjs +8 -1
- package/dist/a11y-X3GULPJY.js +355 -0
- package/dist/chunk-3SEEYC6R.js +1803 -0
- package/dist/chunk-6MCKYCGA.js +63 -0
- package/dist/chunk-AW4GQLOM.js +67 -0
- package/dist/chunk-EAMUH7ZB.js +23 -0
- package/dist/chunk-ICKV4DDG.js +231 -0
- package/dist/chunk-IFKXORJV.js +298 -0
- package/dist/chunk-KAXYG56C.js +185 -0
- package/dist/chunk-MABXT3RT.js +514 -0
- package/dist/chunk-PGTYCHBV.js +18 -0
- package/dist/chunk-RDZE7TZG.js +17 -0
- package/dist/chunk-RQP7ALWK.js +2068 -0
- package/dist/chunk-SKSVFAPZ.js +240 -0
- package/dist/chunk-VSSX6F42.js +27 -0
- package/dist/chunk-WUHORHPY.js +42 -0
- package/dist/chunk-XKBSTVSC.js +14116 -0
- package/dist/chunk-YIZRJPII.js +27 -0
- package/dist/config-3JZDCGLC.js +648 -0
- package/dist/config-schema.d.ts +20 -0
- package/dist/config-schema.js +3 -0
- package/dist/dist-P3PQVEIR.js +2 -0
- package/dist/doctor-AKOPTL62.js +904 -0
- package/dist/evidence-6RUORR7P.js +224 -0
- package/dist/explore-CMTPFLIB.js +423 -0
- package/dist/fix-OUIERLSI.js +796 -0
- package/dist/healing/scripts/capture-page.mjs +19 -2
- package/dist/history-QPKCL64Y.js +495 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.js +765 -14786
- package/dist/init-5DG222SD.js +199 -0
- package/dist/manifest-seal-QXGIQ4LI.js +5 -0
- package/dist/mcp-NQQEZNQW.js +261 -0
- package/dist/migrations/0004_review_records.sql +56 -0
- package/dist/review-AWRKQMIE.js +499 -0
- package/dist/run-XXK4SY7X.js +303 -0
- package/dist/schemas/config-layer-v1.schema.json +381 -0
- package/dist/schemas/config-v1.schema.json +384 -0
- package/dist/snapshot-NWJXLNZC.js +442 -0
- package/dist/snapshot-review-LRRPNAYH.js +7 -0
- package/dist/test-W5BFE5IS.js +10 -0
- package/dist/visual/scripts/capture-screenshot.mjs +3 -0
- package/dist/xray-BON5QPYR.js +674 -0
- package/package.json +8 -8
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolveLlmClient, llmKeyCheck, warnAiSkipped, auditA11ySkill, defectCreateSkill } from './chunk-RQP7ALWK.js';
|
|
3
|
+
import './chunk-VSSX6F42.js';
|
|
4
|
+
import { getQaiosVersion } from './chunk-RDZE7TZG.js';
|
|
5
|
+
import { loadCommandConfig } from './chunk-ICKV4DDG.js';
|
|
6
|
+
import { ExitCode } from './chunk-EAMUH7ZB.js';
|
|
7
|
+
import { loadProjectMemory, Storage, AuditLogger, WorkflowsRepository, runAxe, AxeRunError, ArtifactsRepository, SkillRunner, McpServersRepository, McpClient, fileDefect } from './chunk-XKBSTVSC.js';
|
|
8
|
+
import { ulid } from './chunk-3SEEYC6R.js';
|
|
9
|
+
import { createHash } from 'crypto';
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
|
|
13
|
+
var GLYPH = { ok: "\u2713", failed: "\u2717", warn: "\u26A0", issue: "\u2691" };
|
|
14
|
+
var MAX_TARGETS_SHOWN = 3;
|
|
15
|
+
function formatRawFinding(f) {
|
|
16
|
+
const lines = [` ${GLYPH.issue} [${f.kind}/${f.impact ?? "unknown"}] ${f.id}: ${f.description}`];
|
|
17
|
+
const targets = f.nodes.map((n) => n.target.join(" "));
|
|
18
|
+
if (targets.length > 0) {
|
|
19
|
+
const shown = targets.slice(0, MAX_TARGETS_SHOWN).join(", ");
|
|
20
|
+
const more = targets.length > MAX_TARGETS_SHOWN ? ` (+${targets.length - MAX_TARGETS_SHOWN} more)` : "";
|
|
21
|
+
const noun = targets.length === 1 ? "element" : "elements";
|
|
22
|
+
lines.push(` ${targets.length} ${noun}: ${shown}${more}`);
|
|
23
|
+
}
|
|
24
|
+
if (f.helpUrl.length > 0) lines.push(` ${f.helpUrl}`);
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
27
|
+
async function runA11y(opts) {
|
|
28
|
+
const cwd = path.resolve(opts.cwd ?? process.cwd());
|
|
29
|
+
if (!opts.url || opts.url.trim().length === 0) {
|
|
30
|
+
return {
|
|
31
|
+
exitCode: ExitCode.USER_ERROR,
|
|
32
|
+
error: {
|
|
33
|
+
code: "qaios.a11y.no_url",
|
|
34
|
+
message: "must provide a URL to scan (positional argument)."
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const qaiosDir = path.join(cwd, ".qaios");
|
|
39
|
+
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
40
|
+
return {
|
|
41
|
+
exitCode: ExitCode.USER_ERROR,
|
|
42
|
+
error: {
|
|
43
|
+
code: "qaios.a11y.not_initialized",
|
|
44
|
+
message: `${cwd} is not a QAIOS project \u2014 run \`qaios init\` first.`
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const config = loadCommandConfig(cwd, opts.configSelection);
|
|
49
|
+
const memory = loadProjectMemory(cwd);
|
|
50
|
+
const mode = opts.mode ?? config?.mode ?? "LITE";
|
|
51
|
+
const wcagLevel = opts.wcagLevel ?? "AA";
|
|
52
|
+
const ownsStorage = opts.storage === void 0;
|
|
53
|
+
const storage = opts.storage ?? Storage.open(path.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
54
|
+
const auditLogger = new AuditLogger(storage.db);
|
|
55
|
+
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
56
|
+
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
57
|
+
const writeOut = (line) => {
|
|
58
|
+
if (opts.quiet === true) return;
|
|
59
|
+
if (opts.json === true) process.stdout.write(JSON.stringify({ kind: "log", line }) + "\n");
|
|
60
|
+
else process.stdout.write(line + "\n");
|
|
61
|
+
};
|
|
62
|
+
const finalize = (workflowId, status) => {
|
|
63
|
+
workflowsRepo.update(workflowId, { status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
64
|
+
auditLogger.append({
|
|
65
|
+
workflowId,
|
|
66
|
+
phase: "ORCHESTRATION",
|
|
67
|
+
skillId: null,
|
|
68
|
+
event: status === "succeeded" ? "workflow.completed" : "workflow.failed",
|
|
69
|
+
payload: {},
|
|
70
|
+
actor: "system"
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
const workflowId = ulid();
|
|
75
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
76
|
+
const args = { url: opts.url, wcagLevel, noDefects: opts.noDefects === true };
|
|
77
|
+
workflowsRepo.create({
|
|
78
|
+
id: workflowId,
|
|
79
|
+
command: "a11y",
|
|
80
|
+
args,
|
|
81
|
+
state: "running",
|
|
82
|
+
mode,
|
|
83
|
+
status: "running",
|
|
84
|
+
blockedReason: null,
|
|
85
|
+
cost: { tokens: 0, usdCents: 0 },
|
|
86
|
+
createdAt: now,
|
|
87
|
+
updatedAt: now,
|
|
88
|
+
finishedAt: null
|
|
89
|
+
});
|
|
90
|
+
auditLogger.append({
|
|
91
|
+
workflowId,
|
|
92
|
+
phase: "ORCHESTRATION",
|
|
93
|
+
skillId: null,
|
|
94
|
+
event: "workflow.created",
|
|
95
|
+
payload: { command: "a11y", mode, args },
|
|
96
|
+
actor: "system"
|
|
97
|
+
});
|
|
98
|
+
writeOut(`workflow: ${workflowId}`);
|
|
99
|
+
writeOut("");
|
|
100
|
+
writeOut(
|
|
101
|
+
`Running axe-core accessibility scan (${opts.tags !== void 0 ? `axe tags: ${opts.tags.join(", ")}` : `WCAG ${wcagLevel}`})...`
|
|
102
|
+
);
|
|
103
|
+
const runAxeFn = opts.runAxe ?? runAxe;
|
|
104
|
+
const tags = opts.tags ?? wcagTagsFor(wcagLevel);
|
|
105
|
+
let findings;
|
|
106
|
+
let redirect;
|
|
107
|
+
try {
|
|
108
|
+
findings = await runAxeFn({
|
|
109
|
+
url: opts.url,
|
|
110
|
+
cwd,
|
|
111
|
+
tags,
|
|
112
|
+
onRedirect: (finalUrl) => {
|
|
113
|
+
redirect = finalUrl;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
if (redirect !== void 0) {
|
|
117
|
+
writeOut(
|
|
118
|
+
` ${GLYPH.warn} ${opts.url} redirected to ${redirect}; the scan covers that page. A page behind a login cannot be scanned directly yet.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const code = err instanceof AxeRunError ? err.code : "qaios.a11y.scan_failed";
|
|
123
|
+
const message = err.message ?? String(err);
|
|
124
|
+
finalize(workflowId, "failed");
|
|
125
|
+
writeOut(`${GLYPH.failed} ${code}: ${message}`);
|
|
126
|
+
return { exitCode: ExitCode.TOOL_ERROR, workflowId, error: { code, message } };
|
|
127
|
+
}
|
|
128
|
+
const violationCount = findings.filter((f) => f.kind === "violation").length;
|
|
129
|
+
const incompleteCount = findings.filter((f) => f.kind === "incomplete").length;
|
|
130
|
+
const bestPracticeCount = findings.filter((f) => f.kind === "best_practice").length;
|
|
131
|
+
writeOut(
|
|
132
|
+
` ${GLYPH.ok} scan complete \u2014 ${violationCount} violation(s), ${incompleteCount} needs-review, ${bestPracticeCount} best-practice`
|
|
133
|
+
);
|
|
134
|
+
writeOut("");
|
|
135
|
+
const specsDir = path.join(qaiosDir, "specs", workflowId);
|
|
136
|
+
const writeReport = (report) => {
|
|
137
|
+
mkdirSync(specsDir, { recursive: true });
|
|
138
|
+
const text = JSON.stringify(report, null, 2) + "\n";
|
|
139
|
+
writeFileSync(path.join(specsDir, "A11yReport.json"), text);
|
|
140
|
+
new ArtifactsRepository(storage.db).recordOnce({
|
|
141
|
+
workflowId,
|
|
142
|
+
skillId: "audit.a11y",
|
|
143
|
+
type: "A11yReport",
|
|
144
|
+
path: `.qaios/specs/${workflowId}/A11yReport.json`,
|
|
145
|
+
contentHash: createHash("sha256").update(text).digest("hex"),
|
|
146
|
+
confidence: 1
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
if (findings.length === 0) {
|
|
150
|
+
writeReport({ url: opts.url, wcagLevel, findings: [], issues: [] });
|
|
151
|
+
writeOut("No accessibility issues found.");
|
|
152
|
+
finalize(workflowId, "succeeded");
|
|
153
|
+
return {
|
|
154
|
+
exitCode: ExitCode.SUCCESS,
|
|
155
|
+
workflowId,
|
|
156
|
+
issues: [],
|
|
157
|
+
findings: [],
|
|
158
|
+
findingCount: 0,
|
|
159
|
+
violationCount: 0,
|
|
160
|
+
defectsFiled: 0
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const skillInput = {
|
|
164
|
+
url: opts.url,
|
|
165
|
+
wcagLevel,
|
|
166
|
+
findings: findings.map((f) => ({
|
|
167
|
+
kind: f.kind,
|
|
168
|
+
id: f.id,
|
|
169
|
+
impact: f.impact ?? null,
|
|
170
|
+
description: f.description,
|
|
171
|
+
help: f.help,
|
|
172
|
+
helpUrl: f.helpUrl,
|
|
173
|
+
tags: f.tags,
|
|
174
|
+
nodes: f.nodes.map((n) => ({
|
|
175
|
+
html: n.html,
|
|
176
|
+
target: n.target,
|
|
177
|
+
failureSummary: n.failureSummary
|
|
178
|
+
}))
|
|
179
|
+
}))
|
|
180
|
+
};
|
|
181
|
+
const skillCtx = {
|
|
182
|
+
workflowId,
|
|
183
|
+
projectMemory: "",
|
|
184
|
+
prior: {},
|
|
185
|
+
auditLogger,
|
|
186
|
+
llm,
|
|
187
|
+
...opts.cancelSignal !== void 0 ? { cancelSignal: opts.cancelSignal } : {},
|
|
188
|
+
...memory !== null ? { qaiosMemoryFull: memory } : {}
|
|
189
|
+
};
|
|
190
|
+
let triaged = null;
|
|
191
|
+
const keyCheck = llmKeyCheck(opts.llm, config?.llm);
|
|
192
|
+
if (!keyCheck.ok) {
|
|
193
|
+
if (opts.quiet !== true) warnAiSkipped(keyCheck);
|
|
194
|
+
} else {
|
|
195
|
+
writeOut("Triaging findings (priority + WCAG mapping + remediation)...");
|
|
196
|
+
try {
|
|
197
|
+
const result = await new SkillRunner().run(auditA11ySkill, skillCtx, skillInput);
|
|
198
|
+
triaged = result.output;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
writeOut(
|
|
201
|
+
` ${GLYPH.warn} triage failed (${err.message}); reporting raw axe findings.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const issues = triaged?.issues ?? [];
|
|
206
|
+
writeReport({ url: opts.url, wcagLevel, findings, issues });
|
|
207
|
+
writeOut("");
|
|
208
|
+
const violations = issues.filter((i) => i.kind === "violation");
|
|
209
|
+
const needsReview = issues.filter((i) => i.kind === "incomplete");
|
|
210
|
+
const bestPractice = issues.filter((i) => i.kind === "best_practice");
|
|
211
|
+
if (triaged !== null) {
|
|
212
|
+
if (violations.length > 0) {
|
|
213
|
+
writeOut(`Violations (${violations.length}) \u2014 must fix:`);
|
|
214
|
+
for (const it of violations) {
|
|
215
|
+
writeOut(` ${GLYPH.issue} [${it.priority.toUpperCase()}] ${it.title} (${it.ruleId})`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (needsReview.length > 0) {
|
|
219
|
+
writeOut(`Needs manual review (${needsReview.length}) \u2014 axe couldn't be certain:`);
|
|
220
|
+
for (const it of needsReview) {
|
|
221
|
+
writeOut(` ${GLYPH.warn} ${it.title} (${it.ruleId})`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (bestPractice.length > 0) {
|
|
225
|
+
writeOut(`Best practices (${bestPractice.length}) \u2014 recommendations:`);
|
|
226
|
+
for (const it of bestPractice) {
|
|
227
|
+
writeOut(` \u2022 ${it.title} (${it.ruleId})`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
for (const f of findings) for (const line of formatRawFinding(f)) writeOut(line);
|
|
232
|
+
}
|
|
233
|
+
writeOut("");
|
|
234
|
+
let defectsFiled = 0;
|
|
235
|
+
if (opts.noDefects !== true && violations.length > 0) {
|
|
236
|
+
const target = opts.defectTarget ?? config?.defects?.target ?? "stdout";
|
|
237
|
+
const repo = opts.defectRepo ?? config?.defects?.repo;
|
|
238
|
+
const project = opts.defectProject ?? config?.defects?.project;
|
|
239
|
+
writeOut(`Filing ${violations.length} violation defect(s) (target: ${target})...`);
|
|
240
|
+
let mcpClient = opts.mcpClient;
|
|
241
|
+
if (target !== "stdout" && mcpClient === void 0) {
|
|
242
|
+
const repoMcp = new McpServersRepository(storage.db);
|
|
243
|
+
const servers = repoMcp.list();
|
|
244
|
+
mcpClient = new McpClient({
|
|
245
|
+
servers,
|
|
246
|
+
auditLogger,
|
|
247
|
+
workflowId,
|
|
248
|
+
clientVersion: getQaiosVersion()
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
for (const issue of violations) {
|
|
252
|
+
const filed = await fileIssueAsDefect({
|
|
253
|
+
issue,
|
|
254
|
+
url: opts.url,
|
|
255
|
+
workflowId,
|
|
256
|
+
target,
|
|
257
|
+
repo,
|
|
258
|
+
project,
|
|
259
|
+
mcpClient,
|
|
260
|
+
llm,
|
|
261
|
+
auditLogger,
|
|
262
|
+
memory
|
|
263
|
+
});
|
|
264
|
+
if (filed.success) {
|
|
265
|
+
defectsFiled++;
|
|
266
|
+
const urlSuffix = filed.url !== void 0 ? ` \u2192 ${filed.url}` : "";
|
|
267
|
+
writeOut(` ${GLYPH.ok} ${filed.id ?? "filed"}: ${issue.title}${urlSuffix}`);
|
|
268
|
+
} else {
|
|
269
|
+
writeOut(` ${GLYPH.failed} ${issue.title} \u2014 ${filed.error ?? "unknown failure"}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (mcpClient !== void 0 && opts.mcpClient === void 0) await mcpClient.close();
|
|
273
|
+
}
|
|
274
|
+
finalize(workflowId, "succeeded");
|
|
275
|
+
return {
|
|
276
|
+
exitCode: violationCount > 0 ? ExitCode.USER_ERROR : ExitCode.SUCCESS,
|
|
277
|
+
workflowId,
|
|
278
|
+
issues,
|
|
279
|
+
findings,
|
|
280
|
+
findingCount: findings.length,
|
|
281
|
+
violationCount,
|
|
282
|
+
defectsFiled
|
|
283
|
+
};
|
|
284
|
+
} finally {
|
|
285
|
+
if (ownsStorage) storage.close();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function wcagTagsFor(level) {
|
|
289
|
+
if (level === "A") return ["wcag2a", "wcag21a"];
|
|
290
|
+
if (level === "AAA") return ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa"];
|
|
291
|
+
return ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];
|
|
292
|
+
}
|
|
293
|
+
async function fileIssueAsDefect(args) {
|
|
294
|
+
const i = args.issue;
|
|
295
|
+
const wcag = i.wcagCriterion !== void 0 ? ` (WCAG ${i.wcagCriterion})` : "";
|
|
296
|
+
const skillInput = {
|
|
297
|
+
source: "exploratory_finding",
|
|
298
|
+
context: {
|
|
299
|
+
workflowId: args.workflowId,
|
|
300
|
+
requirementIds: [],
|
|
301
|
+
findingDescription: `Accessibility: ${i.title}${wcag} [axe rule: ${i.ruleId}]`,
|
|
302
|
+
reproSteps: [
|
|
303
|
+
`Run an accessibility scan (axe-core) against ${args.url}.`,
|
|
304
|
+
`Inspect the element(s): ${i.affectedSelectors.slice(0, 5).join(", ") || "(see report)"}.`
|
|
305
|
+
],
|
|
306
|
+
expectedBehavior: `The page conforms to the relevant WCAG${wcag} success criterion.`,
|
|
307
|
+
actualBehavior: `axe-core rule \`${i.ruleId}\` (impact: ${i.axeImpact}) fails. ${i.remediation}`,
|
|
308
|
+
severity: i.priority,
|
|
309
|
+
attachments: [],
|
|
310
|
+
environment: { url: args.url }
|
|
311
|
+
},
|
|
312
|
+
target: {
|
|
313
|
+
integration: args.target,
|
|
314
|
+
...args.repo !== void 0 ? { repo: args.repo } : {},
|
|
315
|
+
...args.project !== void 0 ? { project: args.project } : {},
|
|
316
|
+
labels: ["accessibility", "a11y"]
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const ctx = {
|
|
320
|
+
workflowId: args.workflowId,
|
|
321
|
+
projectMemory: "",
|
|
322
|
+
prior: {},
|
|
323
|
+
auditLogger: args.auditLogger,
|
|
324
|
+
llm: args.llm,
|
|
325
|
+
...args.memory !== null ? { qaiosMemoryFull: args.memory } : {}
|
|
326
|
+
};
|
|
327
|
+
let defectOutput;
|
|
328
|
+
try {
|
|
329
|
+
const result = await new SkillRunner().run(defectCreateSkill, ctx, skillInput);
|
|
330
|
+
defectOutput = result.output;
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return { success: false, error: err.message };
|
|
333
|
+
}
|
|
334
|
+
const rule = `[axe rule: ${i.ruleId}]`;
|
|
335
|
+
const defect = defectOutput.defect.title.includes(rule) ? defectOutput.defect : { ...defectOutput.defect, title: `${defectOutput.defect.title} ${rule}` };
|
|
336
|
+
const filed = await fileDefect({
|
|
337
|
+
defect,
|
|
338
|
+
target: {
|
|
339
|
+
integration: args.target,
|
|
340
|
+
...args.repo !== void 0 ? { repo: args.repo } : {},
|
|
341
|
+
...args.project !== void 0 ? { project: args.project } : {}
|
|
342
|
+
},
|
|
343
|
+
...args.mcpClient !== void 0 ? { mcpClient: args.mcpClient } : {},
|
|
344
|
+
auditLogger: args.auditLogger,
|
|
345
|
+
workflowId: args.workflowId
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
success: filed.success,
|
|
349
|
+
...filed.id !== void 0 ? { id: filed.id } : {},
|
|
350
|
+
...filed.url !== void 0 ? { url: filed.url } : {},
|
|
351
|
+
...filed.error !== void 0 ? { error: filed.error } : {}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export { formatRawFinding, runA11y };
|