@diegovelasquezweb/a11y-engine 0.10.2 → 0.11.1

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.
@@ -1 +1,60 @@
1
- export default {"interactiveRoles":["button","link","textbox","combobox","listbox","menuitem","tab","checkbox","radio","switch","slider"],"rules":[{"id":"cdp-missing-accessible-name","condition":"interactive-no-name","impact":"serious","tags":["wcag2a","wcag412","cdp-check"],"help":"Interactive elements must have an accessible name","helpUrl":"https://dequeuniversity.com/rules/axe/4.11/button-name","description":"Interactive element with role \"{{role}}\" has no accessible name","failureMessage":"Element with role \"{{role}}\" has no accessible name in the accessibility tree","axeEquivalents":["button-name","link-name","input-name","aria-command-name"]},{"id":"cdp-aria-hidden-focusable","condition":"hidden-focusable","impact":"serious","tags":["wcag2a","wcag412","cdp-check"],"help":"aria-hidden elements must not be focusable","helpUrl":"https://dequeuniversity.com/rules/axe/4.11/aria-hidden-focus","description":"Focusable element with role \"{{role}}\" is aria-hidden","failureMessage":"Focusable element with role \"{{role}}\" is hidden from the accessibility tree","axeEquivalents":["aria-hidden-focus"]}]};
1
+ export default {
2
+ "interactiveRoles": ["button","link","textbox","combobox","listbox","menuitem","tab","checkbox","radio","switch","slider"],
3
+ "rules": [
4
+ {
5
+ "id": "cdp-missing-accessible-name",
6
+ "condition": "interactive-no-name",
7
+ "impact": "serious",
8
+ "tags": ["wcag2a","wcag412","cdp-check"],
9
+ "help": "Interactive elements must have an accessible name",
10
+ "helpUrl": "https://dequeuniversity.com/rules/axe/4.11/button-name",
11
+ "description": "Interactive element with role \"{{role}}\" has no accessible name",
12
+ "failureMessage": "Element with role \"{{role}}\" has no accessible name in the accessibility tree",
13
+ "axeEquivalents": ["button-name","link-name","input-name","aria-command-name"]
14
+ },
15
+ {
16
+ "id": "cdp-aria-hidden-focusable",
17
+ "condition": "hidden-focusable",
18
+ "impact": "serious",
19
+ "tags": ["wcag2a","wcag412","cdp-check"],
20
+ "help": "aria-hidden elements must not be focusable",
21
+ "helpUrl": "https://dequeuniversity.com/rules/axe/4.11/aria-hidden-focus",
22
+ "description": "Focusable element with role \"{{role}}\" is aria-hidden",
23
+ "failureMessage": "Focusable element with role \"{{role}}\" is hidden from the accessibility tree",
24
+ "axeEquivalents": ["aria-hidden-focus"]
25
+ },
26
+ {
27
+ "id": "cdp-autoplay-media",
28
+ "condition": "dom-eval",
29
+ "impact": "serious",
30
+ "tags": ["wcag2a","wcag142","wcag222","cdp-check"],
31
+ "help": "Media elements must not autoplay without user control",
32
+ "helpUrl": "https://www.w3.org/WAI/WCAG21/Understanding/audio-control.html",
33
+ "description": "Media element autoplays without user control (WCAG 1.4.2, 2.2.2)",
34
+ "failureMessage": "Media element has autoplay attribute without providing user controls to stop it",
35
+ "axeEquivalents": []
36
+ },
37
+ {
38
+ "id": "cdp-missing-main-landmark",
39
+ "condition": "dom-eval",
40
+ "impact": "moderate",
41
+ "tags": ["wcag2a","wcag131","best-practice","cdp-check"],
42
+ "help": "Page must have a main landmark",
43
+ "helpUrl": "https://dequeuniversity.com/rules/axe/4.11/landmark-one-main",
44
+ "description": "Page does not have a main landmark",
45
+ "failureMessage": "Document does not contain a <main> element or an element with role=\"main\"",
46
+ "axeEquivalents": ["landmark-one-main"]
47
+ },
48
+ {
49
+ "id": "cdp-missing-skip-link",
50
+ "condition": "dom-eval",
51
+ "impact": "moderate",
52
+ "tags": ["wcag2a","wcag241","best-practice","cdp-check"],
53
+ "help": "Page must have a skip navigation link",
54
+ "helpUrl": "https://www.w3.org/WAI/WCAG21/Understanding/bypass-blocks.html",
55
+ "description": "Page does not have a skip navigation link as the first focusable element",
56
+ "failureMessage": "No skip link found as first focusable element — keyboard users cannot bypass navigation",
57
+ "axeEquivalents": ["bypass"]
58
+ }
59
+ ]
60
+ };
@@ -24,6 +24,7 @@
24
24
  - [Constants](#constants)
25
25
  - [VIEWPORT_PRESETS](#viewport_presets)
26
26
  - [DEFAULT_AI_SYSTEM_PROMPT](#default_ai_system_prompt)
27
+ - [PM_AI_SYSTEM_PROMPT](#pm_ai_system_prompt)
27
28
 
28
29
  ---
29
30
 
@@ -52,6 +53,7 @@ import {
52
53
  getKnowledge,
53
54
  VIEWPORT_PRESETS,
54
55
  DEFAULT_AI_SYSTEM_PROMPT,
56
+ PM_AI_SYSTEM_PROMPT,
55
57
  } from "@diegovelasquezweb/a11y-engine";
56
58
  ```
57
59
 
@@ -106,7 +108,7 @@ Returns a `ScanPayload` object consumed by all other functions.
106
108
  | `viewport` | `object` | `{ width: 1280, height: 800 }` | width: 320–2560, height: 320–2560 | Browser viewport dimensions in pixels |
107
109
  | `colorScheme` | `string` | `"light"` | `"light"` \| `"dark"` | Emulates `prefers-color-scheme` media query |
108
110
  | `engines` | `object` | all `true` | `{ axe?, cdp?, pa11y? }` | Which engines to run. At least one must be enabled |
109
- | `axeTags` | `string[]` | WCAG 2.x A+AA | See below | axe-core rule tag filter. Also determines pa11y standard |
111
+ | `axeTags` | `string[]` | WCAG 2.x A+AA | See below | axe-core rule tag filter. Also determines pa11y standard. Add `"best-practice"` and/or `"ACT"` to include non-WCAG best practices and W3C ACT rules (opt-in, not included by default) |
110
112
  | `onlyRule` | `string` | — | axe rule ID | Run a single axe rule only (e.g. `"color-contrast"`) |
111
113
  | `ignoreFindings` | `string[]` | — | axe rule IDs | Suppress specific rules from output entirely |
112
114
  | `excludeSelectors` | `string[]` | — | CSS selectors | Skip elements matching these selectors during axe scan |
@@ -121,6 +123,9 @@ Returns a `ScanPayload` object consumed by all other functions.
121
123
  | `ai.githubToken` | `string` | — | GitHub PAT | Used to fetch source files from the repo for AI context |
122
124
  | `ai.model` | `string` | `"claude-haiku-4-5-20251001"` | Anthropic model ID | Claude model to use |
123
125
  | `ai.systemPrompt` | `string` | Built-in prompt | — | Overrides the default Claude system prompt for the entire scan |
126
+ | `ai.audience` | `string` | `"dev"` | `"dev"` \| `"pm"` | Controls the AI enrichment tone. `"dev"` generates code-level fixes; `"pm"` generates business impact summaries |
127
+ | `clearCache` | `boolean` | `false` | — | Clear browser cache before each page navigation via CDP `Network.clearBrowserCache`. Ensures fresh results on repeated scans of the same domain |
128
+ | `serverMode` | `boolean` | `false` | — | Enable server/EC2/Docker Chrome launch flags: `--no-sandbox`, `--disable-setuid-sandbox`, `--disable-dev-shm-usage`, `--disable-gpu`, `--no-zygote`, `--disable-accelerated-2d-canvas`. Use in CI, Docker, or EC2 environments |
124
129
  | `onProgress` | `function` | — | — | Callback fired at each pipeline step |
125
130
 
126
131
  **`axeTags` common values:**
@@ -134,10 +139,25 @@ Returns a `ScanPayload` object consumed by all other functions.
134
139
  | `wcag22a` | WCAG 2.2 Level A additions |
135
140
  | `wcag22aa` | WCAG 2.2 Level AA additions |
136
141
  | `wcag2aaa` | WCAG 2.0 Level AAA |
137
- | `best-practice` | Non-WCAG best practices |
142
+ | `best-practice` | Non-WCAG best practices (opt-in) |
143
+ | `ACT` | W3C Accessibility Conformance Testing rules (opt-in) |
138
144
 
139
145
  **Supported `framework` values:** `nextjs`, `gatsby`, `react`, `nuxt`, `vue`, `angular`, `astro`, `svelte`, `remix`, `shopify`, `wordpress`, `drupal`
140
146
 
147
+ **CDP checks:**
148
+
149
+ The `cdp` engine runs 5 checks split across two mechanisms:
150
+
151
+ | Check ID | Mechanism | Impact | WCAG |
152
+ | :--- | :--- | :--- | :--- |
153
+ | `cdp-missing-accessible-name` | Accessibility tree | Serious | 4.1.2 A |
154
+ | `cdp-aria-hidden-focusable` | Accessibility tree | Serious | 4.1.2 A |
155
+ | `cdp-autoplay-media` | `page.evaluate()` | Serious | 1.4.2, 2.2.2 A |
156
+ | `cdp-missing-main-landmark` | `page.evaluate()` | Moderate | 1.3.1 A |
157
+ | `cdp-missing-skip-link` | `page.evaluate()` | Moderate | 2.4.1 A |
158
+
159
+ All 5 checks have intelligence enrichment entries (fix description, fix code, framework notes, CMS notes).
160
+
141
161
  **`onProgress` callback:**
142
162
 
143
163
  ```ts
@@ -182,6 +202,9 @@ Returns: `Promise<ScanPayload>`
182
202
  },
183
203
  routes_scanned: number, // How many pages were actually scanned
184
204
  discovery_method: string, // "crawl" | "explicit"
205
+ passesCount: number, // Unique axe rules that passed (deduplicated across routes)
206
+ incompleteCount: number, // Total axe incomplete results across routes (needs manual review)
207
+ inapplicableCount: number, // Unique axe rules that were inapplicable (deduplicated across routes)
185
208
  },
186
209
  incomplete_findings?: RawFinding[], // axe "incomplete" results (needs-review)
187
210
  patternFindings?: { // Only present if projectDir/repoUrl + !skipPatterns
@@ -308,6 +331,11 @@ Returns: `EnrichedFinding[]`
308
331
  guardrails: object | null, // Guardrail metadata from the engine
309
332
  checkData: object | null, // Raw check data from the engine
310
333
 
334
+ // PM audience fields (always present from intelligence DB)
335
+ pmSummary: string | null, // One-line business impact for PMs
336
+ pmImpact: string | null, // Business/legal/UX consequences
337
+ pmEffort: string | null, // "quick-win" | "medium" | "strategic"
338
+
311
339
  // AI enrichment (only when ai.enabled ran)
312
340
  aiEnhanced?: boolean, // true when AI enriched this finding
313
341
  aiFixDescription?: string, // Claude-generated fix explanation
@@ -651,6 +679,18 @@ Type: `string`
651
679
 
652
680
  ---
653
681
 
682
+ ### `PM_AI_SYSTEM_PROMPT`
683
+
684
+ The system prompt used for PM-audience AI enrichment. Instructs Claude to generate business-oriented summaries instead of developer-focused fixes. Used automatically when `ai.audience` is `"pm"`.
685
+
686
+ ```ts
687
+ import { PM_AI_SYSTEM_PROMPT } from "@diegovelasquezweb/a11y-engine";
688
+ ```
689
+
690
+ Type: `string`
691
+
692
+ ---
693
+
654
694
  > **Note on `ai_enriched_findings` fast path**: When `getFindings()` receives a payload that contains `ai_enriched_findings` and no `screenshotUrlBuilder` option is provided, it returns `ai_enriched_findings` directly without re-normalizing the raw `findings` array. If a `screenshotUrlBuilder` is provided, normalization always runs so paths can be rewritten.
655
695
 
656
696
  ---
@@ -82,7 +82,7 @@ flowchart LR
82
82
 
83
83
  | Module | Responsibility |
84
84
  | :--- | :--- |
85
- | `src/pipeline/dom-scanner.mjs` | Route discovery, engine execution (axe/CDP/pa11y), merge/dedup, progress updates, screenshots |
85
+ | `src/pipeline/dom-scanner.mjs` | Route discovery, engine execution (axe/CDP/pa11y), merge/dedup, progress updates, screenshots. CDP checks use two mechanisms: CDP accessibility tree (missing accessible name, aria-hidden focusable) and `page.evaluate()` DOM inspection (autoplay media, missing main landmark, missing skip link). |
86
86
  | `src/enrichment/analyzer.mjs` | Rule enrichment, selector strategy, ownership hints, recommendations, scoring metadata |
87
87
  | `src/ai/enrich.mjs` | CLI subprocess that runs AI enrichment after the analyzer. Reads `ANTHROPIC_API_KEY` and `AI_SYSTEM_PROMPT` env vars. Non-fatal. |
88
88
  | `src/ai/claude.mjs` | Anthropic API client. Sends Critical/Serious findings to Claude and parses improved fix suggestions. Supports custom system prompt and repo source file context. |
@@ -120,6 +120,29 @@ Output shape:
120
120
  Core artifacts generated by the pipeline:
121
121
 
122
122
  - `progress.json`: step status — `page`, `axe`, `cdp`, `pa11y`, `merge`, `intelligence`, `repo` (remote package.json fetch), `patterns` (source pattern scan), `ai` (Claude enrichment)
123
+
124
+ ### CDP Check Mechanisms
125
+
126
+ The CDP engine uses two distinct detection mechanisms within the same `runCdpChecks()` function:
127
+
128
+ | Mechanism | Checks | Why |
129
+ | :--- | :--- | :--- |
130
+ | CDP Accessibility Tree (`Accessibility.getFullAXTree`) | `cdp-missing-accessible-name`, `cdp-aria-hidden-focusable` | Reads the computed accessibility tree — the actual view screen readers see. Required for role/name/state inspection. |
131
+ | DOM evaluation (`page.evaluate()`) | `cdp-autoplay-media`, `cdp-missing-main-landmark`, `cdp-missing-skip-link` | Inspects HTML attributes and DOM structure that are not reflected in the accessibility tree. `autoplay` is a media attribute; landmark and skip link presence are structural checks. |
132
+
133
+ ### Engine Execution Model
134
+
135
+ Within each route, the three engines run with optimized parallelism:
136
+
137
+ ```
138
+ ┌─ axe-core (navigates + analyzes page) ──────────────────────────────┐
139
+ │ └─ CDP checks (reads same loaded page — sequential after axe) ├──→ merge
140
+ └─ pa11y (own shared Puppeteer browser, independent) ─────────────────┘
141
+ ```
142
+
143
+ - **axe → CDP**: Sequential. CDP depends on axe's page navigation. Both share the same Playwright tab.
144
+ - **pa11y**: Starts in parallel with axe. Uses a single shared Puppeteer browser instance (launched once per scan, reused for all routes). This eliminates the Chrome cold-start overhead (1-3s) that would occur if pa11y launched its own browser per route.
145
+ - **Route batching**: Up to 3 routes are scanned in parallel via Playwright tabs. Each route runs its own axe→CDP→(merge with pa11y) sequence.
123
146
  - `a11y-scan-results.json`: merged runtime scan output per route
124
147
  - `a11y-findings.json`: enriched findings payload used by reports and API consumers
125
148
 
@@ -51,7 +51,7 @@ flowchart TD
51
51
 
52
52
  ## Intelligence Database
53
53
 
54
- The engine ships a bundled intelligence database at `assets/remediation/intelligence.mjs`. This contains per-rule entries keyed by axe rule ID (e.g. `color-contrast`, `image-alt`).
54
+ The engine ships a bundled intelligence database at `assets/remediation/intelligence.mjs`. This contains per-rule entries keyed by axe rule ID (e.g. `color-contrast`, `image-alt`) and CDP check IDs (e.g. `cdp-autoplay-media`, `cdp-missing-main-landmark`, `cdp-missing-skip-link`).
55
55
 
56
56
  Each rule entry can include:
57
57
 
@@ -68,6 +68,9 @@ Each rule entry can include:
68
68
  | `cms_notes` | CMS-specific fix guidance |
69
69
  | `preferred_relationship_checks` | axe check IDs to prioritize for relationship hints |
70
70
  | `guardrails` / `guardrails_overrides` | Must/must-not/verify constraints for automated fixes |
71
+ | `pm.summary` | One-line business impact for PM audience (e.g. "Buttons without labels block screen reader users from key actions") |
72
+ | `pm.impact` | Business/legal/UX consequences for non-technical stakeholders |
73
+ | `pm.effort` | Effort classification: `quick-win`, `medium`, or `strategic` |
71
74
 
72
75
  When a framework is detected (e.g. `nextjs`), only the relevant framework notes are included in the output. React-based frameworks (`nextjs`, `gatsby`) resolve to `react` notes.
73
76
 
@@ -250,11 +253,21 @@ AI enrichment runs automatically when `ANTHROPIC_API_KEY` is present in the envi
250
253
 
251
254
  The default system prompt instructs Claude to go beyond the generic fix: explain why the issue matters for users, reference the specific selector and violation data, and provide a more complete code example than the engine's default. The prompt can be overridden per-scan via the `AI_SYSTEM_PROMPT` env var or `options.ai.systemPrompt` in the programmatic API.
252
255
 
256
+ ### PM audience mode
257
+
258
+ When `ai.audience` is set to `"pm"`, the AI enrichment uses a PM-specific system prompt that generates business-oriented guidance instead of developer-focused fixes. The PM prompt instructs Claude to produce:
259
+
260
+ - `pmSummary` — one-line business impact in plain language
261
+ - `pmImpact` — 2-3 sentences on legal/compliance/UX consequences
262
+ - `pmEffort` — `quick-win`, `medium`, or `strategic` with time estimate
263
+
264
+ These AI-generated PM fields override the static PM fields from the intelligence database for Critical and Serious findings, similar to how `fixDescription` overrides the static fix for dev audience.
265
+
253
266
  ## Assets Reference
254
267
 
255
268
  | Asset | Used by | Purpose |
256
269
  | :--- | :--- | :--- |
257
- | `remediation/intelligence.mjs` | analyzer | Per-rule fix descriptions, code, guardrails, framework notes |
270
+ | `remediation/intelligence.mjs` | analyzer | Per-rule fix descriptions, code, guardrails, framework notes. Covers 101 axe-core rules + 3 CDP-specific checks (`cdp-autoplay-media`, `cdp-missing-main-landmark`, `cdp-missing-skip-link`) |
258
271
  | `remediation/axe-check-maps.mjs` | analyzer | Failure mode and relationship hint mappings from axe check IDs |
259
272
  | `remediation/guardrails.mjs` | analyzer | Shared guardrail constraints for safe automated fixes |
260
273
  | `remediation/source-boundaries.mjs` | analyzer, source-scanner | Framework-specific source directory scoping |
package/docs/outputs.md CHANGED
@@ -89,7 +89,8 @@ Merged results from all three engines (axe-core + CDP + pa11y) per route. Writte
89
89
  "url": "https://example.com/",
90
90
  "violations": [...],
91
91
  "incomplete": [...],
92
- "passes": [...]
92
+ "passes": ["aria-hidden-focus", "document-title", ...],
93
+ "inapplicable": ["video-caption", "audio-caption", ...]
93
94
  }
94
95
  ]
95
96
  }
@@ -128,6 +129,9 @@ The primary enriched data artifact. Written by `src/enrichment/analyzer.mjs`. Th
128
129
  | `recommendations` | `object[]` | Grouped fix recommendations by component |
129
130
  | `testingMethodology` | `object` | Scan scope and methodology summary |
130
131
  | `fpFiltered` | `number` | Count of findings filtered as likely false positives |
132
+ | `passesCount` | `number` | Unique axe-core rules that passed (deduplicated across all scanned routes) |
133
+ | `incompleteCount` | `number` | Total axe-core incomplete results across all routes — items that need manual review |
134
+ | `inapplicableCount` | `number` | Unique axe-core rules that were inapplicable (deduplicated across all scanned routes) |
131
135
  | `deduplicatedCount` | `number` | Count of duplicate findings removed |
132
136
 
133
137
  ### `findings` — per-finding fields
@@ -181,6 +185,9 @@ The primary enriched data artifact. Written by `src/enrichment/analyzer.mjs`. Th
181
185
  | `verification_command_fallback` | `string\|null` | Fallback verify command |
182
186
  | `pages_affected` | `number\|null` | Number of pages with this violation |
183
187
  | `affected_urls` | `string[]\|null` | All URLs where this violation appears |
188
+ | `pm_summary` | `string\|null` | One-line business impact for PM audience. Populated from the intelligence database for all findings. |
189
+ | `pm_impact` | `string\|null` | Business/legal/UX consequences for non-technical stakeholders. |
190
+ | `pm_effort` | `string\|null` | Effort classification: `quick-win`, `medium`, or `strategic`. |
184
191
  | `aiEnhanced` | `boolean` | `true` when Claude improved the fix for this finding. Only present on AI-enriched findings. |
185
192
  | `ai_fix_description` | `string\|null` | Claude-generated fix description. More specific than `fix_description` — references the actual selector, colors, and violation data. Only present when `aiEnhanced` is `true`. |
186
193
  | `ai_fix_code` | `string\|null` | Claude-generated code snippet in the correct framework syntax. Separate from the engine's `fix_code`. Only present when `aiEnhanced` is `true`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
4
4
  "description": "WCAG 2.2 accessibility audit engine — scanner, analyzer, and report builders",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ai/claude.mjs CHANGED
@@ -41,6 +41,21 @@ Rules:
41
41
  - If the violation data contains specific values (colors, ratios, labels), use them in your response
42
42
  - Respond in JSON only — no markdown, no explanation outside the JSON structure`;
43
43
 
44
+ export const PM_AI_SYSTEM_PROMPT = `You are an accessibility compliance advisor for product managers and non-technical stakeholders.
45
+
46
+ Your task is to provide a business-oriented summary for each accessibility finding — something a PM can use to prioritize, communicate to stakeholders, and plan sprints.
47
+
48
+ For each finding, provide:
49
+ 1. pmSummary: A single sentence describing who is affected and what they cannot do. Use plain language, no technical jargon.
50
+ 2. pmImpact: 2-3 sentences on business consequences: legal/compliance risk, user segments blocked, effect on conversions/engagement/SEO. Be specific to the violation.
51
+ 3. pmEffort: One of "quick-win", "medium", or "strategic" with a brief time estimate (e.g., "quick-win — under 1 hour per instance").
52
+
53
+ Rules:
54
+ - Write for a non-technical audience — no code, no selectors, no ARIA terminology
55
+ - Focus on users affected, business risk, and prioritization
56
+ - Reference the actual violation data (title, severity, affected users) to be specific
57
+ - Respond in JSON only — no markdown, no explanation outside the JSON structure`;
58
+
44
59
  function buildSystemPrompt(context) {
45
60
  const { framework, cms, uiLibraries } = context.stack || {};
46
61
 
@@ -98,6 +113,37 @@ function buildUserMessage(findings, sourceFiles) {
98
113
  return message;
99
114
  }
100
115
 
116
+ /**
117
+ * Builds the user message for PM audience.
118
+ * @param {EnrichedFinding[]} findings
119
+ * @returns {string}
120
+ */
121
+ function buildPmUserMessage(findings) {
122
+ const items = findings.map((f, i) => ({
123
+ index: i,
124
+ title: f.title,
125
+ severity: f.severity,
126
+ wcag: f.wcag,
127
+ impactedUsers: f.impactedUsers,
128
+ currentPmSummary: f.pmSummary,
129
+ currentPmImpact: f.pmImpact,
130
+ totalInstances: f.totalInstances,
131
+ pagesAffected: f.pagesAffected,
132
+ }));
133
+
134
+ let message = `Improve the PM-facing guidance for these ${findings.length} accessibility finding(s).\n\n`;
135
+ message += `FINDINGS:\n${JSON.stringify(items, null, 2)}\n`;
136
+ message += `\nRespond with a JSON array where each item has:\n`;
137
+ message += `{
138
+ "index": <number>,
139
+ "pmSummary": "<one-line business impact>",
140
+ "pmImpact": "<2-3 sentences on business/legal/UX consequences>",
141
+ "pmEffort": "<quick-win|medium|strategic with time estimate>"
142
+ }`;
143
+
144
+ return message;
145
+ }
146
+
101
147
  /**
102
148
  * Calls the Claude API with the given messages.
103
149
  * @param {string} apiKey
@@ -266,6 +312,7 @@ export async function enrichWithAI(findings, context = {}, options = {}) {
266
312
  if (!enabled) return findings;
267
313
 
268
314
  const model = options.model || DEFAULT_MODEL;
315
+ const audience = options.audience || "dev";
269
316
  const customSystemPrompt = options.systemPrompt || null;
270
317
 
271
318
  // Only enrich Critical and Serious findings, cap total
@@ -281,11 +328,16 @@ export async function enrichWithAI(findings, context = {}, options = {}) {
281
328
  ? await fetchSourceFilesForFindings(targets, context.repoUrl, options.githubToken)
282
329
  : {};
283
330
 
284
- const systemPrompt = customSystemPrompt || buildSystemPrompt({
285
- stack: context.stack,
286
- hasSourceCode: Object.keys(sourceFiles).length > 0,
287
- });
288
- const userMessage = buildUserMessage(targets, sourceFiles);
331
+ const isPm = audience === "pm";
332
+ const systemPrompt = customSystemPrompt || (isPm
333
+ ? PM_AI_SYSTEM_PROMPT
334
+ : buildSystemPrompt({
335
+ stack: context.stack,
336
+ hasSourceCode: Object.keys(sourceFiles).length > 0,
337
+ }));
338
+ const userMessage = isPm
339
+ ? buildPmUserMessage(targets)
340
+ : buildUserMessage(targets, sourceFiles);
289
341
 
290
342
  const responseText = await callClaude(options.apiKey, model, systemPrompt, userMessage);
291
343
 
@@ -311,6 +363,15 @@ export async function enrichWithAI(findings, context = {}, options = {}) {
311
363
  const imp = improvementMap.get(targetIdx);
312
364
  if (!imp) return finding;
313
365
 
366
+ if (isPm) {
367
+ return {
368
+ ...finding,
369
+ pmSummary: imp.pmSummary || finding.pmSummary,
370
+ pmImpact: imp.pmImpact || finding.pmImpact,
371
+ pmEffort: imp.pmEffort || finding.pmEffort,
372
+ };
373
+ }
374
+
314
375
  return {
315
376
  ...finding,
316
377
  fixDescription: imp.fixDescription || finding.fixDescription,
@@ -639,6 +639,29 @@ function computeOverallAssessment(findings) {
639
639
  return "Pass";
640
640
  }
641
641
 
642
+ /**
643
+ * Computes pass/incomplete/inapplicable counts across all scanned routes.
644
+ * @param {Object[]} routes - Raw scan routes.
645
+ * @returns {{ passesCount: number, incompleteCount: number, inapplicableCount: number }}
646
+ */
647
+ function computeAxeCounts(routes) {
648
+ const passedRules = new Set();
649
+ const inapplicableRules = new Set();
650
+ let incompleteCount = 0;
651
+
652
+ for (const route of routes) {
653
+ for (const ruleId of route.passes || []) passedRules.add(ruleId);
654
+ for (const ruleId of route.inapplicable || []) inapplicableRules.add(ruleId);
655
+ incompleteCount += (route.incomplete || []).length;
656
+ }
657
+
658
+ return {
659
+ passesCount: passedRules.size,
660
+ incompleteCount,
661
+ inapplicableCount: inapplicableRules.size,
662
+ };
663
+ }
664
+
642
665
  /**
643
666
  * Aggregates WCAG 2.2 AA criteria that passed across all scanned routes.
644
667
  * @param {Object[]} routes - Raw scan routes with a passes array of rule IDs.
@@ -907,6 +930,9 @@ function buildFindings(inputPayload, cliArgs) {
907
930
  needs_verification: !!v._fromIncomplete,
908
931
  verification_command: `pnpm a11y --base-url ${route.url} --routes ${route.path} --only-rule ${v.id} --max-routes 1`,
909
932
  verification_command_fallback: `node scripts/audit.mjs --base-url ${route.url} --routes ${route.path} --only-rule ${v.id} --max-routes 1`,
933
+ pm_summary: ruleInfo.pm?.summary ?? null,
934
+ pm_impact: ruleInfo.pm?.impact ?? null,
935
+ pm_effort: ruleInfo.pm?.effort ?? null,
910
936
  });
911
937
  }
912
938
  }
@@ -1044,6 +1070,7 @@ export function runAnalyzer(scanPayload, options = {}) {
1044
1070
  const recommendations = computeRecommendations(dedupedFindings);
1045
1071
  const testingMethodology = computeTestingMethodology(scanPayload);
1046
1072
  const incompleteFindings = collectIncompleteFindings(scanPayload.routes || []);
1073
+ const axeCounts = computeAxeCounts(scanPayload.routes || []);
1047
1074
  if (incompleteFindings.length > 0)
1048
1075
  log.info(`${incompleteFindings.length} incomplete finding(s) require manual review.`);
1049
1076
 
@@ -1060,6 +1087,9 @@ export function runAnalyzer(scanPayload, options = {}) {
1060
1087
  testingMethodology,
1061
1088
  fpFiltered: fpRemovedCount,
1062
1089
  deduplicatedCount,
1090
+ passesCount: axeCounts.passesCount,
1091
+ incompleteCount: axeCounts.incompleteCount,
1092
+ inapplicableCount: axeCounts.inapplicableCount,
1063
1093
  },
1064
1094
  };
1065
1095
 
package/src/index.d.mts CHANGED
@@ -52,6 +52,9 @@ export interface Finding {
52
52
  pages_affected?: number | null;
53
53
  affected_urls?: string[] | null;
54
54
  needs_verification?: boolean;
55
+ pm_summary: string | null;
56
+ pm_impact: string | null;
57
+ pm_effort: string | null;
55
58
  }
56
59
 
57
60
  export interface EnrichedFinding {
@@ -104,6 +107,9 @@ export interface EnrichedFinding {
104
107
  pagesAffected: number | null;
105
108
  affectedUrls: string[] | null;
106
109
  needsVerification?: boolean;
110
+ pmSummary: string | null;
111
+ pmImpact: string | null;
112
+ pmEffort: string | null;
107
113
  }
108
114
 
109
115
  export interface SeverityTotals {
@@ -141,9 +147,32 @@ export interface AuditSummary {
141
147
 
142
148
 
143
149
 
150
+ export interface ScanMetadata {
151
+ target_url?: string;
152
+ scanned_at?: string;
153
+ engines?: { axe: boolean; cdp: boolean; pa11y: boolean };
154
+ projectContext?: { framework: string | null; cms: string | null; uiLibraries: string[] };
155
+ routes_scanned?: number;
156
+ discovery_method?: string;
157
+ overallAssessment?: Record<string, unknown>;
158
+ passedCriteria?: string[];
159
+ outOfScope?: Record<string, unknown>;
160
+ recommendations?: unknown[];
161
+ testingMethodology?: Record<string, unknown>;
162
+ fpFiltered?: number;
163
+ deduplicatedCount?: number;
164
+ /** Total number of unique axe-core rules that passed (deduplicated across all scanned routes). */
165
+ passesCount?: number;
166
+ /** Total number of axe-core incomplete results across all scanned routes (needs manual review). */
167
+ incompleteCount?: number;
168
+ /** Total number of unique axe-core rules that were inapplicable (deduplicated across all scanned routes). */
169
+ inapplicableCount?: number;
170
+ [key: string]: unknown;
171
+ }
172
+
144
173
  export interface ScanPayload {
145
174
  findings: Finding[] | Record<string, unknown>[];
146
- metadata?: Record<string, unknown>;
175
+ metadata?: ScanMetadata;
147
176
  incomplete_findings?: unknown[];
148
177
  }
149
178
 
@@ -391,7 +420,31 @@ export interface RunAuditOptions {
391
420
  waitUntil?: string;
392
421
  colorScheme?: string;
393
422
  viewport?: { width: number; height: number };
423
+ /**
424
+ * axe-core rule tag filter. Also determines the pa11y standard used.
425
+ * Default: `["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22a","wcag22aa"]`
426
+ *
427
+ * Optional opt-in tags (not included by default):
428
+ * - `"best-practice"` — non-WCAG best practices (duplicate IDs, landmark structure, etc.)
429
+ * - `"ACT"` — W3C Accessibility Conformance Testing rules
430
+ * - `"wcag2aaa"` / `"wcag21aaa"` — WCAG Level AAA rules
431
+ */
394
432
  axeTags?: string[];
433
+ /**
434
+ * Clear browser cache before each page navigation.
435
+ * Ensures fresh scan results when scanning the same domain multiple times.
436
+ * Uses CDP `Network.clearBrowserCache` + `Network.setCacheDisabled`.
437
+ * Default: `false`
438
+ */
439
+ clearCache?: boolean;
440
+ /**
441
+ * Enable server/EC2/Docker-optimized Chrome launch flags.
442
+ * Adds: `--no-sandbox`, `--disable-setuid-sandbox`, `--disable-dev-shm-usage`,
443
+ * `--disable-gpu`, `--no-zygote`, `--disable-accelerated-2d-canvas`.
444
+ * Use this when running in CI, Docker, or EC2 environments.
445
+ * Default: `false`
446
+ */
447
+ serverMode?: boolean;
395
448
  onlyRule?: string;
396
449
  excludeSelectors?: string[];
397
450
  ignoreFindings?: string[];
@@ -457,6 +510,7 @@ export function getSourcePatterns(
457
510
  export function getKnowledge(options?: KnowledgeOptions): EngineKnowledge;
458
511
 
459
512
  export const DEFAULT_AI_SYSTEM_PROMPT: string;
513
+ export const PM_AI_SYSTEM_PROMPT: string;
460
514
 
461
515
  export interface ViewportPreset {
462
516
  label: string;
@@ -472,4 +526,5 @@ export interface AiOptions {
472
526
  githubToken?: string;
473
527
  model?: string;
474
528
  systemPrompt?: string;
529
+ audience?: "pm" | "dev";
475
530
  }
package/src/index.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { ASSET_PATHS, loadAssetJson } from "./core/asset-loader.mjs";
8
- export { DEFAULT_AI_SYSTEM_PROMPT } from "./ai/claude.mjs";
8
+ export { DEFAULT_AI_SYSTEM_PROMPT, PM_AI_SYSTEM_PROMPT } from "./ai/claude.mjs";
9
9
 
10
10
  // Lazy-loaded asset cache
11
11
 
@@ -162,6 +162,9 @@ function normalizeSingleFinding(item, index, screenshotUrlBuilder) {
162
162
  pages_affected: typeof item.pages_affected === "number" ? item.pages_affected : null,
163
163
  affected_urls: Array.isArray(item.affected_urls) ? item.affected_urls : null,
164
164
  needs_verification: Boolean(item.needs_verification),
165
+ pm_summary: strOrNull(item.pm_summary),
166
+ pm_impact: strOrNull(item.pm_impact),
167
+ pm_effort: strOrNull(item.pm_effort),
165
168
  };
166
169
  }
167
170
 
@@ -249,6 +252,9 @@ export function getFindings(input, options = {}) {
249
252
  pagesAffected: finding.pages_affected,
250
253
  affectedUrls: finding.affected_urls,
251
254
  needsVerification: finding.needs_verification,
255
+ pmSummary: finding.pm_summary ?? null,
256
+ pmImpact: finding.pm_impact ?? null,
257
+ pmEffort: finding.pm_effort ?? null,
252
258
  };
253
259
 
254
260
  // Enrich from intelligence if no fix data exists yet
@@ -688,6 +694,8 @@ export async function runAudit(options) {
688
694
  projectDir: options.projectDir,
689
695
  remotePackageJson,
690
696
  engines,
697
+ clearCache: options.clearCache ?? false,
698
+ serverMode: options.serverMode ?? false,
691
699
  },
692
700
  { onProgress },
693
701
  );
@@ -793,6 +801,7 @@ export async function runAudit(options) {
793
801
  apiKey: aiOptions.apiKey,
794
802
  githubToken: aiOptions.githubToken || options.githubToken,
795
803
  model: aiOptions.model,
804
+ audience: aiOptions.audience || "dev",
796
805
  }
797
806
  );
798
807