@webability/core 1.8.0 → 1.9.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.
@@ -113,6 +113,12 @@ interface JudgeOptions {
113
113
  apiUrl?: string;
114
114
  /** Bearer token for the hosted path. */
115
115
  apiToken?: string;
116
+ /**
117
+ * Hosted path only: the shared secret that identifies the hosted MCP server
118
+ * to the platform (sent as `x-mcp-internal`). Lets the platform judge scans
119
+ * that carry no user token. Never set on local installs.
120
+ */
121
+ internalSecret?: string;
116
122
  /** Total judge budget per scan (all batches). Default 20 000 ms. */
117
123
  timeoutMs?: number;
118
124
  /** Conformance ceiling: only criteria at or below this level are promoted. Default AA. */
@@ -121,6 +127,11 @@ interface JudgeOptions {
121
127
  vision?: boolean;
122
128
  /** Test / custom model injection. Wins over apiKey/apiUrl. */
123
129
  transport?: JudgeTransport;
130
+ /**
131
+ * Largest element crop (base64 chars) to send. Default: JUDGE_MAX_CROP_CHARS on
132
+ * the hosted path (the endpoint answers 413 above it, PNG only), none elsewhere.
133
+ */
134
+ maxCropChars?: number;
124
135
  }
125
136
  interface JudgeRunOptions extends JudgeOptions {
126
137
  url: string;
@@ -134,6 +145,32 @@ interface JudgeOutcome {
134
145
  judged: JudgedEntry$1[];
135
146
  }
136
147
  declare function resetJudgeCache(): void;
148
+ /** The judge's system prompt. Exported so a server-side judge (platform /api/judge-incomplete) sends the same rules, not a copy. */
149
+ declare const JUDGE_SYSTEM_PROMPT = "You are the final accessibility judge for a WCAG scanner. Automated engines could not decide the findings below. You MUST decide each one: \"pass\" (not a violation) or \"violation\". There is no third option \u2014 never answer \"unsure\", never defer to a human.\n\nRules:\n- Images (1.1.1): \"pass\" when the image is decorative, purely presentational, a spacer/tracking pixel, or is named by the text of its enclosing link/button. Otherwise \"violation\" with fix.attribute=\"alt\" and a concrete alt text written from the image and its context: \u2264125 characters, no \"image of\"/\"picture of\" prefix, no file names.\n- CSS background images (1.1.1) on a control with no accessible name (context.control.accessibleName is empty): the image is the control's only content, so \"violation\" with fix.value = a short name for what the control does, written from the crop and nearby text (\u2264125 characters, no \"image of\"). \"pass\" only if the control is clearly not meant to be operated.\n- Images of text (1.4.5): logos, wordmarks and brand signatures are exempt \u2192 \"pass\". Essential informational text rendered as an image \u2192 \"violation\".\n- Text contrast (1.4.3 / 1.4.6): you receive the text colour, the required ratio for its font size, and the best/worst measured ratio behind the glyphs plus a crop. \"violation\" when the text is not readable at the required ratio over a meaningful part of its background; then fix.attribute=\"style\" and fix.value = a 6-digit hex text colour that meets the ratio against the background's dominant colour. \"pass\" when the text clearly meets the ratio everywhere the glyphs sit.\n- Link purpose (2.4.4): the context is the link text plus its sentence, paragraph, list item or table cell (nearbyText). \"pass\" when the text names the destination page or topic (Home, About, Info, Contact, Pricing, a product or article name), even as a single word in a site menu. \"violation\" only for generic text that names no destination (here, click here, more, read more, learn more, link) when that context does not supply it.\n- Dead in-page link (2.4.1): the item is an <a href=\"#fragment\"> near the top of the page whose fragment matches no id or <a name> (context.engineNote names it). WCAG 2.4.1 is about bypassing repeated blocks. \"violation\" only when the link is the page's bypass mechanism (a skip link that should jump past the header or navigation) and its target is missing, so activating it goes nowhere. Any other dead fragment link is \"pass\" for 2.4.1: an ordinary or script-driven link (tab, accordion, modal or menu trigger, back-to-top).\n- Form with no submit control (3.2.2): the item is a <form> with visible fields and no submit button, input type=submit/image or outside button. \"violation\" only when changing a field or pressing Enter causes an unexpected change of context (navigation, submission, reload) and no submit control exists to ask for it. \"pass\" for a script-driven form, a single search box, or a form whose fields change nothing until the user acts.\n- Decorative image with a title (1.1.1): the item is an <img alt=\"\"> that also has a non-empty title (context.engineNote gives it). \"violation\" when the picture conveys information the title names (a logo, chart, labelled icon, photo of a named subject), so the empty alt hides it. \"pass\" when the picture is purely decorative and the title only repeats nearby text or adds nothing. Do not write alt text for this item.\n- Non-text contrast (1.4.11), motion (2.2.2 / 2.3.3), duplicate ids, ARIA references, captions and everything else: decide from the markup, the context and the crop. For a \"violation\", give fix.attribute/fix.value only when you are certain of the exact value.\n- Be strict: \"pass\" only when confident the finding is not a real barrier.\n\nRespond with ONLY a JSON array, one object per item, same ids:\n[{\"id\": \"...\", \"verdict\": \"pass\" | \"violation\", \"reason\": \"one sentence\", \"fix\": {\"attribute\": \"...\", \"value\": \"...\"}}]\nOmit \"fix\" for passes.";
150
+ interface JudgeChatBody {
151
+ model: string;
152
+ temperature: number;
153
+ max_tokens: number;
154
+ reasoning?: {
155
+ enabled: false;
156
+ };
157
+ messages: Array<{
158
+ role: 'system' | 'user';
159
+ content: unknown;
160
+ }>;
161
+ }
162
+ /**
163
+ * The chat-completions body for one judge batch: the vision model only when the
164
+ * batch is a vision batch AND carries a crop (crops as image_url parts), else the
165
+ * text model. `disableTextModelReasoning`: the default text model reasons unless
166
+ * told not to, and on a real 30-item batch spent all 4096 tokens reasoning (no
167
+ * verdicts, 48 s); set it only for endpoints that accept the `reasoning` field.
168
+ */
169
+ declare function buildJudgeChatBody(req: JudgeRequest, cfg?: {
170
+ visionModel?: string;
171
+ textModel?: string;
172
+ disableTextModelReasoning?: boolean;
173
+ }): JudgeChatBody;
137
174
  /** OpenAI-compatible chat completions (OpenRouter). Vision → image_url parts; text-only → text model. */
138
175
  declare function openAiCompatibleTransport(cfg: {
139
176
  apiKey: string;
@@ -144,10 +181,12 @@ declare function openAiCompatibleTransport(cfg: {
144
181
  /**
145
182
  * Hosted path: the platform judges on the server. Request body = JudgeRequest;
146
183
  * response = `{ verdicts: JudgeVerdict[] }`. Any non-2xx → items stay dropped.
184
+ * The platform answers only a user bearer or the hosted MCP's `x-mcp-internal`.
147
185
  */
148
186
  declare function hostedJudgeTransport(cfg: {
149
187
  apiUrl: string;
150
188
  token?: string;
189
+ internalSecret?: string;
151
190
  }): JudgeTransport;
152
191
  declare function judgeUndecided(page: PageLike | null, undecided: readonly ScanIssue[], opts: JudgeRunOptions): Promise<JudgeOutcome>;
153
192
 
@@ -178,7 +217,7 @@ interface PageLike {
178
217
  };
179
218
  }
180
219
  /** All fix issue types the pipeline can detect and fix. */
181
- type FixIssueType = 'weak_button_name' | 'weak_link_name' | 'weak_heading' | 'weak_iframe_title' | 'weak_page_title' | 'weak_svg_name' | 'weak_skip_link' | 'missing_button_role' | 'missing_semantic_role' | 'missing_landmark' | 'heading_skip' | 'svg_missing_name' | 'aria_hidden_focusable' | 'adjacent_redundant_link' | 'contrast_insufficient' | 'non_text_contrast_insufficient' | 'target_too_small' | 'focus_not_visible' | 'empty_link' | 'unlabeled_button' | 'missing_label' | 'decorative_icon' | 'skip_link' | 'aria_live' | 'missing_alt' | 'identify_input_purpose' | 'redundant_role' | 'duplicate_id' | 'new_window_link' | 'missing_fieldset_legend' | 'missing_table_header' | 'missing_table_caption' | 'missing_table_scope' | 'external_link_warning' | 'download_link_warning' | 'broken_anchor' | 'placeholder_link' | 'missing_pattern_title' | 'missing_autocomplete' | 'missing_media_controls' | 'unsafe_autoplay' | 'invalid_aria_role' | 'zoom_restriction' | 'missing_button_type' | 'link_consistency' | 'semantic_emphasis' | 'keyboard_trap' | 'focus_order_disruption' | 'missing_captions' | 'color_only_link' | 'label_in_name' | 'onfocus_context_change' | 'focus_obscured_by_sticky' | 'text_spacing_blocked' | 'nested_landmark' | 'combobox_name_missing' | 'landmark_duplicate_unlabeled' | 'motion_uncontrollable' | 'auto_refresh' | 'motion_without_reduced_motion' | 'flashing_content' | 'animated_gif_no_pause' | 'missing_required_indicator' | 'background_image_unlabeled' | 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'javascript_link' | 'broken_aria_reference' | 'main_content_hidden' | 'scrollable_not_focusable' | 'meaningful_sequence' | 'sensory_characteristic' | 'orientation_lock' | 'image_of_text' | 'reflow_overflow' | 'missing_audio_description' | 'missing_transcript' | 'character_key_shortcut' | 'on_input_change' | 'pointer_gesture' | 'pointer_cancellation' | 'motion_actuation' | 'error_prevention_missing' | 'insufficient_navigation' | 'inconsistent_navigation';
220
+ type FixIssueType = 'weak_button_name' | 'weak_link_name' | 'weak_heading' | 'weak_iframe_title' | 'weak_page_title' | 'weak_svg_name' | 'weak_skip_link' | 'missing_button_role' | 'missing_semantic_role' | 'missing_document_language' | 'missing_landmark' | 'svg_missing_name' | 'aria_hidden_focusable' | 'adjacent_redundant_link' | 'contrast_insufficient' | 'non_text_contrast_insufficient' | 'target_too_small' | 'focus_not_visible' | 'empty_link' | 'unlabeled_button' | 'missing_label' | 'decorative_icon' | 'skip_link' | 'aria_live' | 'missing_alt' | 'identify_input_purpose' | 'redundant_role' | 'duplicate_id' | 'new_window_link' | 'missing_fieldset_legend' | 'missing_table_header' | 'missing_table_caption' | 'missing_table_scope' | 'external_link_warning' | 'download_link_warning' | 'broken_anchor' | 'form_missing_submit' | 'decorative_image_with_title' | 'placeholder_link' | 'missing_pattern_title' | 'missing_autocomplete' | 'missing_media_controls' | 'unsafe_autoplay' | 'zoom_restriction' | 'missing_button_type' | 'link_consistency' | 'keyboard_trap' | 'focus_order_disruption' | 'missing_captions' | 'color_only_link' | 'label_in_name' | 'onfocus_context_change' | 'focus_obscured_by_sticky' | 'text_spacing_blocked' | 'nested_landmark' | 'motion_uncontrollable' | 'auto_refresh' | 'motion_without_reduced_motion' | 'flashing_content' | 'animated_gif_no_pause' | 'missing_required_indicator' | 'background_image_unlabeled' | 'empty_heading' | 'javascript_link' | 'broken_aria_reference' | 'main_content_hidden' | 'scrollable_not_focusable' | 'meaningful_sequence' | 'sensory_characteristic' | 'image_of_text' | 'reflow_overflow' | 'missing_audio_description' | 'missing_transcript' | 'character_key_shortcut' | 'on_input_change' | 'pointer_gesture' | 'pointer_cancellation' | 'motion_actuation' | 'error_prevention_missing' | 'inconsistent_navigation';
182
221
  /** WCAG criterion for each issue type. */
183
222
  declare const WCAG_BY_ISSUE: Record<FixIssueType, string>;
184
223
  /** A single accessibility issue detected on the page. */
@@ -318,6 +357,31 @@ interface JudgedEntry {
318
357
  verdict: 'pass' | 'violation';
319
358
  reason: string;
320
359
  }
360
+ /**
361
+ * A finding or a page change the scanner's own tooling caused (invariant P).
362
+ * Gate metric, bar = 0: any entry means the scan measured our state, not the page.
363
+ * - state-not-restored: a tool left focus / focus-visible / scroll / selection /
364
+ * injected nodes changed (the scanner restored it)
365
+ * - finding-dropped: a finding sat on an injected (marked) node or on an
366
+ * element a tool left focused; it was not reported
367
+ */
368
+ interface InternalBug {
369
+ tool: string;
370
+ kind: 'state-not-restored' | 'finding-dropped';
371
+ detail: string;
372
+ selector?: string;
373
+ /**
374
+ * Diagnosis only: what the page DOM did inside this tool's window (the
375
+ * mutation-log slice). Pages mutate themselves, so this is never used to
376
+ * attribute a finding; it is there so a bug is traceable. Capped.
377
+ */
378
+ mutations?: Array<{
379
+ type: string;
380
+ attr?: string | null;
381
+ target: string;
382
+ added: string[];
383
+ }>;
384
+ }
321
385
  interface ScanResult {
322
386
  url: string;
323
387
  timestamp: string;
@@ -342,6 +406,18 @@ interface ScanResult {
342
406
  * disables all contrast checks for the page. Absent = all subsystems healthy.
343
407
  */
344
408
  engineWarnings?: string[];
409
+ /**
410
+ * Undecided findings dropped with no verdict: no resolver measurement, no
411
+ * model-free rule and no judge verdict (judge off, failed or silent). For
412
+ * these criteria an absent issue is NOT evidence of a pass. Absent = every
413
+ * undecided finding within the level got a verdict.
414
+ */
415
+ unsettled?: {
416
+ count: number;
417
+ wcag: string[];
418
+ };
419
+ /** Invariant P: findings / page changes caused by our own tooling. Always present; `[]` = clean. */
420
+ internal_bugs?: InternalBug[];
345
421
  /** Engine (core package) version that produced this result — provenance */
346
422
  engine?: string;
347
423
  /**
@@ -351,9 +427,10 @@ interface ScanResult {
351
427
  */
352
428
  incomplete: ScanIssue[];
353
429
  /**
354
- * What the AI judge decided about findings the engines could not: every
355
- * verdict (`pass` = cleared with a reason, `violation` = promoted into
356
- * `issues`). Absent when the judge ran on nothing or is not configured.
430
+ * Verdicts on findings the engines could not decide: model-free rules (a
431
+ * measured contrast pass, a decorative or 1x1 image, a reduced-motion rule)
432
+ * and, when configured, the AI judge. `pass` = cleared with a reason,
433
+ * `violation` = promoted into `issues`. Absent when nothing got a verdict.
357
434
  */
358
435
  judged?: JudgedEntry[];
359
436
  summary: {
@@ -374,12 +451,20 @@ declare const VIEWPORT_SIZES: Record<ViewportPreset, {
374
451
  }>;
375
452
  interface ScanOptions {
376
453
  wcagTags?: string[];
454
+ /** @internal test seam: extra detectors run serially, each in its own state guard. */
455
+ extraSerialDetectors?: FixDetector[];
377
456
  /**
378
457
  * Highest WCAG conformance level to report. Default 'AA': findings whose
379
458
  * criteria are all Level AAA are not reported anywhere (issues, incomplete,
380
459
  * summary). 'AAA' opts in (and adds axe's wcag2aaa rules); 'A' keeps Level A only.
381
460
  */
382
461
  level?: WcagLevel;
462
+ /**
463
+ * Run axe-core (default true). axe is the only engine for the checks it
464
+ * covers on the ACT test suite: form-field / select / combobox names and
465
+ * valid ARIA roles have no WebAbility detector. false = detector-only scan
466
+ * without those checks.
467
+ */
383
468
  includeAxe?: boolean;
384
469
  deep?: boolean;
385
470
  deepApiUrl?: string;
@@ -420,9 +505,10 @@ interface ScanOptions {
420
505
  * AI judge for findings the engines could not decide. With a model key
421
506
  * (local, OpenRouter-compatible) or `apiUrl` (hosted), each such finding
422
507
  * becomes a verdict: a violation with a fix in `issues`, or a recorded pass
423
- * in `judged`. Unset / empty = judge skipped; undecided findings are dropped.
508
+ * in `judged`. Unset / empty = no model calls: only the model-free verdicts
509
+ * run, and findings they cannot settle are dropped (listed in `unsettled`).
424
510
  */
425
511
  judge?: JudgeOptions;
426
512
  }
427
513
 
428
- export { type BrowserDetector as B, type DetectedIssue as D, type FixDetector as F, type IssueImpact as I, type JudgeOptions as J, type PageLike as P, type ScanResult as S, VIEWPORT_SIZES as V, type WcagLevel as W, type ScanOptions as a, type SiteTheme as b, type ScanIssue as c, DEFAULT_JUDGE_BASE_URL as d, DEFAULT_JUDGE_TEXT_MODEL as e, DEFAULT_JUDGE_VISION_MODEL as f, type FixIssueType as g, type IssueSource as h, JUDGE_DEFAULT_TIMEOUT_MS as i, type JudgeItem as j, type JudgeRequest as k, type JudgeTransport as l, type JudgeVerdict as m, type JudgedEntry as n, type ViewportPreset as o, WCAG_BY_ISSUE as p, hostedJudgeTransport as q, judgeUndecided as r, openAiCompatibleTransport as s, resetJudgeCache as t };
514
+ export { type BrowserDetector as B, type DetectedIssue as D, type FixDetector as F, type InternalBug as I, JUDGE_DEFAULT_TIMEOUT_MS as J, type PageLike as P, type ScanOptions as S, VIEWPORT_SIZES as V, type WcagLevel as W, type ScanResult as a, type SiteTheme as b, type ScanIssue as c, DEFAULT_JUDGE_BASE_URL as d, DEFAULT_JUDGE_TEXT_MODEL as e, DEFAULT_JUDGE_VISION_MODEL as f, type FixIssueType as g, type IssueImpact as h, type IssueSource as i, JUDGE_SYSTEM_PROMPT as j, type JudgeChatBody as k, type JudgeItem as l, type JudgeOptions as m, type JudgeRequest as n, type JudgeTransport as o, type JudgeVerdict as p, type JudgedEntry as q, type ViewportPreset as r, WCAG_BY_ISSUE as s, buildJudgeChatBody as t, hostedJudgeTransport as u, judgeUndecided as v, openAiCompatibleTransport as w, resetJudgeCache as x };
@@ -113,6 +113,12 @@ interface JudgeOptions {
113
113
  apiUrl?: string;
114
114
  /** Bearer token for the hosted path. */
115
115
  apiToken?: string;
116
+ /**
117
+ * Hosted path only: the shared secret that identifies the hosted MCP server
118
+ * to the platform (sent as `x-mcp-internal`). Lets the platform judge scans
119
+ * that carry no user token. Never set on local installs.
120
+ */
121
+ internalSecret?: string;
116
122
  /** Total judge budget per scan (all batches). Default 20 000 ms. */
117
123
  timeoutMs?: number;
118
124
  /** Conformance ceiling: only criteria at or below this level are promoted. Default AA. */
@@ -121,6 +127,11 @@ interface JudgeOptions {
121
127
  vision?: boolean;
122
128
  /** Test / custom model injection. Wins over apiKey/apiUrl. */
123
129
  transport?: JudgeTransport;
130
+ /**
131
+ * Largest element crop (base64 chars) to send. Default: JUDGE_MAX_CROP_CHARS on
132
+ * the hosted path (the endpoint answers 413 above it, PNG only), none elsewhere.
133
+ */
134
+ maxCropChars?: number;
124
135
  }
125
136
  interface JudgeRunOptions extends JudgeOptions {
126
137
  url: string;
@@ -134,6 +145,32 @@ interface JudgeOutcome {
134
145
  judged: JudgedEntry$1[];
135
146
  }
136
147
  declare function resetJudgeCache(): void;
148
+ /** The judge's system prompt. Exported so a server-side judge (platform /api/judge-incomplete) sends the same rules, not a copy. */
149
+ declare const JUDGE_SYSTEM_PROMPT = "You are the final accessibility judge for a WCAG scanner. Automated engines could not decide the findings below. You MUST decide each one: \"pass\" (not a violation) or \"violation\". There is no third option \u2014 never answer \"unsure\", never defer to a human.\n\nRules:\n- Images (1.1.1): \"pass\" when the image is decorative, purely presentational, a spacer/tracking pixel, or is named by the text of its enclosing link/button. Otherwise \"violation\" with fix.attribute=\"alt\" and a concrete alt text written from the image and its context: \u2264125 characters, no \"image of\"/\"picture of\" prefix, no file names.\n- CSS background images (1.1.1) on a control with no accessible name (context.control.accessibleName is empty): the image is the control's only content, so \"violation\" with fix.value = a short name for what the control does, written from the crop and nearby text (\u2264125 characters, no \"image of\"). \"pass\" only if the control is clearly not meant to be operated.\n- Images of text (1.4.5): logos, wordmarks and brand signatures are exempt \u2192 \"pass\". Essential informational text rendered as an image \u2192 \"violation\".\n- Text contrast (1.4.3 / 1.4.6): you receive the text colour, the required ratio for its font size, and the best/worst measured ratio behind the glyphs plus a crop. \"violation\" when the text is not readable at the required ratio over a meaningful part of its background; then fix.attribute=\"style\" and fix.value = a 6-digit hex text colour that meets the ratio against the background's dominant colour. \"pass\" when the text clearly meets the ratio everywhere the glyphs sit.\n- Link purpose (2.4.4): the context is the link text plus its sentence, paragraph, list item or table cell (nearbyText). \"pass\" when the text names the destination page or topic (Home, About, Info, Contact, Pricing, a product or article name), even as a single word in a site menu. \"violation\" only for generic text that names no destination (here, click here, more, read more, learn more, link) when that context does not supply it.\n- Dead in-page link (2.4.1): the item is an <a href=\"#fragment\"> near the top of the page whose fragment matches no id or <a name> (context.engineNote names it). WCAG 2.4.1 is about bypassing repeated blocks. \"violation\" only when the link is the page's bypass mechanism (a skip link that should jump past the header or navigation) and its target is missing, so activating it goes nowhere. Any other dead fragment link is \"pass\" for 2.4.1: an ordinary or script-driven link (tab, accordion, modal or menu trigger, back-to-top).\n- Form with no submit control (3.2.2): the item is a <form> with visible fields and no submit button, input type=submit/image or outside button. \"violation\" only when changing a field or pressing Enter causes an unexpected change of context (navigation, submission, reload) and no submit control exists to ask for it. \"pass\" for a script-driven form, a single search box, or a form whose fields change nothing until the user acts.\n- Decorative image with a title (1.1.1): the item is an <img alt=\"\"> that also has a non-empty title (context.engineNote gives it). \"violation\" when the picture conveys information the title names (a logo, chart, labelled icon, photo of a named subject), so the empty alt hides it. \"pass\" when the picture is purely decorative and the title only repeats nearby text or adds nothing. Do not write alt text for this item.\n- Non-text contrast (1.4.11), motion (2.2.2 / 2.3.3), duplicate ids, ARIA references, captions and everything else: decide from the markup, the context and the crop. For a \"violation\", give fix.attribute/fix.value only when you are certain of the exact value.\n- Be strict: \"pass\" only when confident the finding is not a real barrier.\n\nRespond with ONLY a JSON array, one object per item, same ids:\n[{\"id\": \"...\", \"verdict\": \"pass\" | \"violation\", \"reason\": \"one sentence\", \"fix\": {\"attribute\": \"...\", \"value\": \"...\"}}]\nOmit \"fix\" for passes.";
150
+ interface JudgeChatBody {
151
+ model: string;
152
+ temperature: number;
153
+ max_tokens: number;
154
+ reasoning?: {
155
+ enabled: false;
156
+ };
157
+ messages: Array<{
158
+ role: 'system' | 'user';
159
+ content: unknown;
160
+ }>;
161
+ }
162
+ /**
163
+ * The chat-completions body for one judge batch: the vision model only when the
164
+ * batch is a vision batch AND carries a crop (crops as image_url parts), else the
165
+ * text model. `disableTextModelReasoning`: the default text model reasons unless
166
+ * told not to, and on a real 30-item batch spent all 4096 tokens reasoning (no
167
+ * verdicts, 48 s); set it only for endpoints that accept the `reasoning` field.
168
+ */
169
+ declare function buildJudgeChatBody(req: JudgeRequest, cfg?: {
170
+ visionModel?: string;
171
+ textModel?: string;
172
+ disableTextModelReasoning?: boolean;
173
+ }): JudgeChatBody;
137
174
  /** OpenAI-compatible chat completions (OpenRouter). Vision → image_url parts; text-only → text model. */
138
175
  declare function openAiCompatibleTransport(cfg: {
139
176
  apiKey: string;
@@ -144,10 +181,12 @@ declare function openAiCompatibleTransport(cfg: {
144
181
  /**
145
182
  * Hosted path: the platform judges on the server. Request body = JudgeRequest;
146
183
  * response = `{ verdicts: JudgeVerdict[] }`. Any non-2xx → items stay dropped.
184
+ * The platform answers only a user bearer or the hosted MCP's `x-mcp-internal`.
147
185
  */
148
186
  declare function hostedJudgeTransport(cfg: {
149
187
  apiUrl: string;
150
188
  token?: string;
189
+ internalSecret?: string;
151
190
  }): JudgeTransport;
152
191
  declare function judgeUndecided(page: PageLike | null, undecided: readonly ScanIssue[], opts: JudgeRunOptions): Promise<JudgeOutcome>;
153
192
 
@@ -178,7 +217,7 @@ interface PageLike {
178
217
  };
179
218
  }
180
219
  /** All fix issue types the pipeline can detect and fix. */
181
- type FixIssueType = 'weak_button_name' | 'weak_link_name' | 'weak_heading' | 'weak_iframe_title' | 'weak_page_title' | 'weak_svg_name' | 'weak_skip_link' | 'missing_button_role' | 'missing_semantic_role' | 'missing_landmark' | 'heading_skip' | 'svg_missing_name' | 'aria_hidden_focusable' | 'adjacent_redundant_link' | 'contrast_insufficient' | 'non_text_contrast_insufficient' | 'target_too_small' | 'focus_not_visible' | 'empty_link' | 'unlabeled_button' | 'missing_label' | 'decorative_icon' | 'skip_link' | 'aria_live' | 'missing_alt' | 'identify_input_purpose' | 'redundant_role' | 'duplicate_id' | 'new_window_link' | 'missing_fieldset_legend' | 'missing_table_header' | 'missing_table_caption' | 'missing_table_scope' | 'external_link_warning' | 'download_link_warning' | 'broken_anchor' | 'placeholder_link' | 'missing_pattern_title' | 'missing_autocomplete' | 'missing_media_controls' | 'unsafe_autoplay' | 'invalid_aria_role' | 'zoom_restriction' | 'missing_button_type' | 'link_consistency' | 'semantic_emphasis' | 'keyboard_trap' | 'focus_order_disruption' | 'missing_captions' | 'color_only_link' | 'label_in_name' | 'onfocus_context_change' | 'focus_obscured_by_sticky' | 'text_spacing_blocked' | 'nested_landmark' | 'combobox_name_missing' | 'landmark_duplicate_unlabeled' | 'motion_uncontrollable' | 'auto_refresh' | 'motion_without_reduced_motion' | 'flashing_content' | 'animated_gif_no_pause' | 'missing_required_indicator' | 'background_image_unlabeled' | 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'javascript_link' | 'broken_aria_reference' | 'main_content_hidden' | 'scrollable_not_focusable' | 'meaningful_sequence' | 'sensory_characteristic' | 'orientation_lock' | 'image_of_text' | 'reflow_overflow' | 'missing_audio_description' | 'missing_transcript' | 'character_key_shortcut' | 'on_input_change' | 'pointer_gesture' | 'pointer_cancellation' | 'motion_actuation' | 'error_prevention_missing' | 'insufficient_navigation' | 'inconsistent_navigation';
220
+ type FixIssueType = 'weak_button_name' | 'weak_link_name' | 'weak_heading' | 'weak_iframe_title' | 'weak_page_title' | 'weak_svg_name' | 'weak_skip_link' | 'missing_button_role' | 'missing_semantic_role' | 'missing_document_language' | 'missing_landmark' | 'svg_missing_name' | 'aria_hidden_focusable' | 'adjacent_redundant_link' | 'contrast_insufficient' | 'non_text_contrast_insufficient' | 'target_too_small' | 'focus_not_visible' | 'empty_link' | 'unlabeled_button' | 'missing_label' | 'decorative_icon' | 'skip_link' | 'aria_live' | 'missing_alt' | 'identify_input_purpose' | 'redundant_role' | 'duplicate_id' | 'new_window_link' | 'missing_fieldset_legend' | 'missing_table_header' | 'missing_table_caption' | 'missing_table_scope' | 'external_link_warning' | 'download_link_warning' | 'broken_anchor' | 'form_missing_submit' | 'decorative_image_with_title' | 'placeholder_link' | 'missing_pattern_title' | 'missing_autocomplete' | 'missing_media_controls' | 'unsafe_autoplay' | 'zoom_restriction' | 'missing_button_type' | 'link_consistency' | 'keyboard_trap' | 'focus_order_disruption' | 'missing_captions' | 'color_only_link' | 'label_in_name' | 'onfocus_context_change' | 'focus_obscured_by_sticky' | 'text_spacing_blocked' | 'nested_landmark' | 'motion_uncontrollable' | 'auto_refresh' | 'motion_without_reduced_motion' | 'flashing_content' | 'animated_gif_no_pause' | 'missing_required_indicator' | 'background_image_unlabeled' | 'empty_heading' | 'javascript_link' | 'broken_aria_reference' | 'main_content_hidden' | 'scrollable_not_focusable' | 'meaningful_sequence' | 'sensory_characteristic' | 'image_of_text' | 'reflow_overflow' | 'missing_audio_description' | 'missing_transcript' | 'character_key_shortcut' | 'on_input_change' | 'pointer_gesture' | 'pointer_cancellation' | 'motion_actuation' | 'error_prevention_missing' | 'inconsistent_navigation';
182
221
  /** WCAG criterion for each issue type. */
183
222
  declare const WCAG_BY_ISSUE: Record<FixIssueType, string>;
184
223
  /** A single accessibility issue detected on the page. */
@@ -318,6 +357,31 @@ interface JudgedEntry {
318
357
  verdict: 'pass' | 'violation';
319
358
  reason: string;
320
359
  }
360
+ /**
361
+ * A finding or a page change the scanner's own tooling caused (invariant P).
362
+ * Gate metric, bar = 0: any entry means the scan measured our state, not the page.
363
+ * - state-not-restored: a tool left focus / focus-visible / scroll / selection /
364
+ * injected nodes changed (the scanner restored it)
365
+ * - finding-dropped: a finding sat on an injected (marked) node or on an
366
+ * element a tool left focused; it was not reported
367
+ */
368
+ interface InternalBug {
369
+ tool: string;
370
+ kind: 'state-not-restored' | 'finding-dropped';
371
+ detail: string;
372
+ selector?: string;
373
+ /**
374
+ * Diagnosis only: what the page DOM did inside this tool's window (the
375
+ * mutation-log slice). Pages mutate themselves, so this is never used to
376
+ * attribute a finding; it is there so a bug is traceable. Capped.
377
+ */
378
+ mutations?: Array<{
379
+ type: string;
380
+ attr?: string | null;
381
+ target: string;
382
+ added: string[];
383
+ }>;
384
+ }
321
385
  interface ScanResult {
322
386
  url: string;
323
387
  timestamp: string;
@@ -342,6 +406,18 @@ interface ScanResult {
342
406
  * disables all contrast checks for the page. Absent = all subsystems healthy.
343
407
  */
344
408
  engineWarnings?: string[];
409
+ /**
410
+ * Undecided findings dropped with no verdict: no resolver measurement, no
411
+ * model-free rule and no judge verdict (judge off, failed or silent). For
412
+ * these criteria an absent issue is NOT evidence of a pass. Absent = every
413
+ * undecided finding within the level got a verdict.
414
+ */
415
+ unsettled?: {
416
+ count: number;
417
+ wcag: string[];
418
+ };
419
+ /** Invariant P: findings / page changes caused by our own tooling. Always present; `[]` = clean. */
420
+ internal_bugs?: InternalBug[];
345
421
  /** Engine (core package) version that produced this result — provenance */
346
422
  engine?: string;
347
423
  /**
@@ -351,9 +427,10 @@ interface ScanResult {
351
427
  */
352
428
  incomplete: ScanIssue[];
353
429
  /**
354
- * What the AI judge decided about findings the engines could not: every
355
- * verdict (`pass` = cleared with a reason, `violation` = promoted into
356
- * `issues`). Absent when the judge ran on nothing or is not configured.
430
+ * Verdicts on findings the engines could not decide: model-free rules (a
431
+ * measured contrast pass, a decorative or 1x1 image, a reduced-motion rule)
432
+ * and, when configured, the AI judge. `pass` = cleared with a reason,
433
+ * `violation` = promoted into `issues`. Absent when nothing got a verdict.
357
434
  */
358
435
  judged?: JudgedEntry[];
359
436
  summary: {
@@ -374,12 +451,20 @@ declare const VIEWPORT_SIZES: Record<ViewportPreset, {
374
451
  }>;
375
452
  interface ScanOptions {
376
453
  wcagTags?: string[];
454
+ /** @internal test seam: extra detectors run serially, each in its own state guard. */
455
+ extraSerialDetectors?: FixDetector[];
377
456
  /**
378
457
  * Highest WCAG conformance level to report. Default 'AA': findings whose
379
458
  * criteria are all Level AAA are not reported anywhere (issues, incomplete,
380
459
  * summary). 'AAA' opts in (and adds axe's wcag2aaa rules); 'A' keeps Level A only.
381
460
  */
382
461
  level?: WcagLevel;
462
+ /**
463
+ * Run axe-core (default true). axe is the only engine for the checks it
464
+ * covers on the ACT test suite: form-field / select / combobox names and
465
+ * valid ARIA roles have no WebAbility detector. false = detector-only scan
466
+ * without those checks.
467
+ */
383
468
  includeAxe?: boolean;
384
469
  deep?: boolean;
385
470
  deepApiUrl?: string;
@@ -420,9 +505,10 @@ interface ScanOptions {
420
505
  * AI judge for findings the engines could not decide. With a model key
421
506
  * (local, OpenRouter-compatible) or `apiUrl` (hosted), each such finding
422
507
  * becomes a verdict: a violation with a fix in `issues`, or a recorded pass
423
- * in `judged`. Unset / empty = judge skipped; undecided findings are dropped.
508
+ * in `judged`. Unset / empty = no model calls: only the model-free verdicts
509
+ * run, and findings they cannot settle are dropped (listed in `unsettled`).
424
510
  */
425
511
  judge?: JudgeOptions;
426
512
  }
427
513
 
428
- export { type BrowserDetector as B, type DetectedIssue as D, type FixDetector as F, type IssueImpact as I, type JudgeOptions as J, type PageLike as P, type ScanResult as S, VIEWPORT_SIZES as V, type WcagLevel as W, type ScanOptions as a, type SiteTheme as b, type ScanIssue as c, DEFAULT_JUDGE_BASE_URL as d, DEFAULT_JUDGE_TEXT_MODEL as e, DEFAULT_JUDGE_VISION_MODEL as f, type FixIssueType as g, type IssueSource as h, JUDGE_DEFAULT_TIMEOUT_MS as i, type JudgeItem as j, type JudgeRequest as k, type JudgeTransport as l, type JudgeVerdict as m, type JudgedEntry as n, type ViewportPreset as o, WCAG_BY_ISSUE as p, hostedJudgeTransport as q, judgeUndecided as r, openAiCompatibleTransport as s, resetJudgeCache as t };
514
+ export { type BrowserDetector as B, type DetectedIssue as D, type FixDetector as F, type InternalBug as I, JUDGE_DEFAULT_TIMEOUT_MS as J, type PageLike as P, type ScanOptions as S, VIEWPORT_SIZES as V, type WcagLevel as W, type ScanResult as a, type SiteTheme as b, type ScanIssue as c, DEFAULT_JUDGE_BASE_URL as d, DEFAULT_JUDGE_TEXT_MODEL as e, DEFAULT_JUDGE_VISION_MODEL as f, type FixIssueType as g, type IssueImpact as h, type IssueSource as i, JUDGE_SYSTEM_PROMPT as j, type JudgeChatBody as k, type JudgeItem as l, type JudgeOptions as m, type JudgeRequest as n, type JudgeTransport as o, type JudgeVerdict as p, type JudgedEntry as q, type ViewportPreset as r, WCAG_BY_ISSUE as s, buildJudgeChatBody as t, hostedJudgeTransport as u, judgeUndecided as v, openAiCompatibleTransport as w, resetJudgeCache as x };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webability/core",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Accessibility scanner engine — WCAG 2.2 AA compliance testing with 200+ rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -21,6 +21,18 @@
21
21
  "import": "./dist/playwright.js",
22
22
  "require": "./dist/playwright.cjs",
23
23
  "types": "./dist/playwright.d.ts"
24
+ },
25
+ "./engine": {
26
+ "import": "./dist/engine.js",
27
+ "require": "./dist/engine.cjs",
28
+ "types": "./dist/engine.d.ts"
29
+ }
30
+ },
31
+ "typesVersions": {
32
+ "*": {
33
+ "engine": [
34
+ "dist/engine.d.ts"
35
+ ]
24
36
  }
25
37
  },
26
38
  "files": [
@@ -30,7 +42,7 @@
30
42
  "scripts": {
31
43
  "build": "npm run gen:dom-kernel && tsup",
32
44
  "dev": "tsup --watch",
33
- "test": "vitest run",
45
+ "test": "WA_STRICT_STATE=1 vitest run",
34
46
  "test:watch": "vitest",
35
47
  "typecheck": "tsc --noEmit",
36
48
  "clean": "rm -rf dist",
@@ -56,7 +68,8 @@
56
68
  "directory": "core"
57
69
  },
58
70
  "dependencies": {
59
- "@axe-core/playwright": "^4.11.3"
71
+ "@axe-core/playwright": "^4.11.3",
72
+ "css-what": "^6.2.2"
60
73
  },
61
74
  "peerDependencies": {
62
75
  "playwright": "^1.40.0"
@@ -68,6 +81,7 @@
68
81
  },
69
82
  "devDependencies": {
70
83
  "axe-core": "^4.11.3",
84
+ "esbuild": "0.27.7",
71
85
  "playwright": "1.59.1",
72
86
  "tsup": "^8.3.0",
73
87
  "typescript": "^5.6.0",