Context
playwright-axe-auditor.ts maps each axe violation to five fields — id, impact, description, helpUrl, nodes — and drops everything else on the floor. Three of the discarded fields are the ones downstream features keep wanting:
| Field |
What it carries |
Currently |
tags |
The rule's standard mapping — wcag2a, wcag2aa, wcag143, cat.color, best-practice |
Discarded |
help |
axe's short imperative fix summary, distinct from description |
Discarded |
nodes[].failureSummary |
axe's per-node "Fix any of the following…" text |
Discarded |
We already fetch all three on every audit. Nothing needs to be computed, scraped, or inferred — they arrive in the same object we are already mapping and are thrown away one line later.
The cost of waiting is asymmetric. Every audit run before this ships has no tags, and there is no way to derive them afterwards from what we stored: ruleId alone cannot tell you which success criterion a rule maps to without shipping our own copy of axe's rule metadata, which is exactly the kind of second source of truth that goes stale. Back-filling means re-auditing.
Scope
In
- Capture
tags, help and nodes[].failureSummary in the auditor's result.
- Carry them through
AddViolationParams and ViolationModel.
- Persist them.
Out
- Interpreting any of it. Turning
tags into success criteria is its own issue; this one only stops discarding the input.
- Exposing them on the API. See the boundary note below — that is a deliberate second step.
Implementation notes
tags is string[]. Either a text[] column or jsonb. A column with a GIN index is the better shape if anything will ever filter by standard, which is the whole point of keeping it.
- jsonb writes must be stringified.
nodes already goes through JSON.stringify; anything new following that route must too. node-postgres serialises a JS array as a Postgres array literal, which the jsonb parser rejects.
failureSummary is per node, not per violation, so it belongs inside the nodes jsonb rather than beside description.
help vs description are genuinely different. description states the rule ("Elements must meet minimum contrast ratio thresholds"); help states the action. Storing only one of them is what makes remediation copy read like a lint rule.
The boundary this must not cross by accident
toAuditResultResponse in presentation/helpers/audit-view.ts is a security boundary, not a convenience mapper: AuditModel carries pageId, which links to a site and therefore an account, so every field is named explicitly and there is a spec asserting the forbidden keys are absent.
Adding fields to the model must not turn that mapper into a spread. And note the share page at /r/:uuid is fully public — anything added to the audit response becomes world-readable for every shared audit. Deciding which of these three fields is public is part of the follow-up issue, not this one; this one stops at the database.
Acceptance criteria
Context
playwright-axe-auditor.tsmaps each axe violation to five fields —id,impact,description,helpUrl,nodes— and drops everything else on the floor. Three of the discarded fields are the ones downstream features keep wanting:tagswcag2a,wcag2aa,wcag143,cat.color,best-practicehelpdescriptionnodes[].failureSummaryWe already fetch all three on every audit. Nothing needs to be computed, scraped, or inferred — they arrive in the same object we are already mapping and are thrown away one line later.
The cost of waiting is asymmetric. Every audit run before this ships has no tags, and there is no way to derive them afterwards from what we stored:
ruleIdalone cannot tell you which success criterion a rule maps to without shipping our own copy of axe's rule metadata, which is exactly the kind of second source of truth that goes stale. Back-filling means re-auditing.Scope
In
tags,helpandnodes[].failureSummaryin the auditor's result.AddViolationParamsandViolationModel.Out
tagsinto success criteria is its own issue; this one only stops discarding the input.Implementation notes
tagsisstring[]. Either atext[]column or jsonb. A column with a GIN index is the better shape if anything will ever filter by standard, which is the whole point of keeping it.nodesalready goes throughJSON.stringify; anything new following that route must too. node-postgres serialises a JS array as a Postgres array literal, which the jsonb parser rejects.failureSummaryis per node, not per violation, so it belongs inside thenodesjsonb rather than besidedescription.helpvsdescriptionare genuinely different.descriptionstates the rule ("Elements must meet minimum contrast ratio thresholds");helpstates the action. Storing only one of them is what makes remediation copy read like a lint rule.The boundary this must not cross by accident
toAuditResultResponseinpresentation/helpers/audit-view.tsis a security boundary, not a convenience mapper:AuditModelcarriespageId, which links to a site and therefore an account, so every field is named explicitly and there is a spec asserting the forbidden keys are absent.Adding fields to the model must not turn that mapper into a spread. And note the share page at
/r/:uuidis fully public — anything added to the audit response becomes world-readable for every shared audit. Deciding which of these three fields is public is part of the follow-up issue, not this one; this one stops at the database.Acceptance criteria
tags,help, and per-nodefailureSummary.tagsfor a known rule, rather than asserting the plumbing in isolation.toAuditResultResponseis unchanged, and its "forbidden keys" spec still passes.