@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.
@@ -0,0 +1,565 @@
1
+ import { c as ScanIssue, I as InternalBug, P as PageLike } from './types-g-bI_MhR.cjs';
2
+
3
+ /**
4
+ * The one engine's contract: two injected drivers in, one result out.
5
+ *
6
+ * runEngine never launches a browser, calls a provider or touches storage on
7
+ * its own (the bundle carries scan()'s URL-string branch, which it never
8
+ * takes). A consumer passes a `Page` (Playwright, Puppeteer, a sandbox) and,
9
+ * for agent tasks, a `Model` (any OpenAI-compatible chat call). Everything the
10
+ * engine reads or writes goes through those two objects. No agent stage runs
11
+ * yet: the rubric agent for judgment criteria is its own step.
12
+ */
13
+
14
+ /**
15
+ * Minimal browser page. A Playwright page needs a thin adapter
16
+ * (`fromPlaywright`: its `evaluate` takes one argument). Optional members widen what the engine can
17
+ * do; each absent one is named in `stats.warnings`, never guessed around.
18
+ *
19
+ * Isolation is the driver's: for `html` input the engine removes `<script>`
20
+ * (HTML and SVG) and meta refresh but keeps inline handler attributes (detectors read them),
21
+ * so the driver must give the page no egress it would not give a scanned site
22
+ * (the hosted MCP's SSRF route, a sandbox), or turn JavaScript off.
23
+ */
24
+ interface Page {
25
+ /** A Response-like return (`status()`) is read for the main document's HTTP status. */
26
+ goto(url: string, opts?: {
27
+ waitUntil?: any;
28
+ timeout?: number;
29
+ }): Promise<unknown>;
30
+ evaluate<R>(pageFunction: string | ((...args: any[]) => R | Promise<R>), ...args: any[]): Promise<R>;
31
+ /** Must honour `clip`, `fullPage` and `animations`: pixel contrast crops by `clip`. */
32
+ screenshot(opts?: {
33
+ type?: 'png';
34
+ encoding?: 'base64';
35
+ clip?: {
36
+ x: number;
37
+ y: number;
38
+ width: number;
39
+ height: number;
40
+ };
41
+ fullPage?: boolean;
42
+ animations?: 'disabled' | 'allow';
43
+ }): Promise<Uint8Array | string>;
44
+ /** focusVisible presses Tab to put the browser in keyboard modality. */
45
+ keyboard: {
46
+ press(key: string): Promise<void>;
47
+ };
48
+ /** Wait for the document's `load` event (render settle). Absent: settle waits on animations only, so late iframes/media may be missed. */
49
+ waitForLoadState?(state: 'load', opts?: {
50
+ timeout?: number;
51
+ }): Promise<void>;
52
+ /** Current URL. Absent: the input URL is used. */
53
+ url?(): string;
54
+ /**
55
+ * Serve `html` as the document at `url` and navigate there (no network fetch
56
+ * of the page itself). Absent: a snapshot stays on about:blank with a
57
+ * `<base href>`, and `location` is about:blank.
58
+ */
59
+ loadHtml?(html: string, url: string): Promise<void>;
60
+ /** Script injection (Playwright/Puppeteer). Absent: string `evaluate` is used. */
61
+ addScriptTag?(opts: {
62
+ content?: string;
63
+ url?: string;
64
+ }): Promise<unknown>;
65
+ }
66
+ /** One OpenAI-compatible chat call. `signal` aborts the HTTP request at the engine's deadline. */
67
+ interface ModelRequest {
68
+ model?: string;
69
+ messages: any[];
70
+ tools?: any[];
71
+ max_tokens: number;
72
+ temperature?: number;
73
+ reasoning_effort?: 'low' | 'medium' | 'high';
74
+ }
75
+ interface ModelResponse {
76
+ message: {
77
+ content?: string | null;
78
+ tool_calls?: any[];
79
+ };
80
+ /** OpenAI usage; `cost` (USD) when the gateway reports it. */
81
+ usage?: {
82
+ prompt_tokens?: number;
83
+ completion_tokens?: number;
84
+ prompt_tokens_details?: {
85
+ cached_tokens?: number;
86
+ };
87
+ cost?: number;
88
+ };
89
+ }
90
+ interface Model {
91
+ chat(req: ModelRequest, opts?: {
92
+ signal?: AbortSignal;
93
+ }): Promise<ModelResponse>;
94
+ }
95
+ /**
96
+ * What to scan. `url` alone: the engine loads it on the page. `html`: a DOM
97
+ * snapshot (the widget Worker's input) loaded into the page, with `url` as its
98
+ * base and the source of the fix records' page path.
99
+ */
100
+ type EngineInput = {
101
+ url: string;
102
+ html?: undefined;
103
+ } | {
104
+ html: string;
105
+ url?: string;
106
+ };
107
+ interface EngineOptions {
108
+ /** Highest WCAG level reported. Default 'AA'. */
109
+ level?: 'A' | 'AA' | 'AAA';
110
+ /** HTTP status of the main document when the caller navigated. */
111
+ mainStatus?: number;
112
+ }
113
+ /**
114
+ * One fix record. The first five keys are the served manifest's wire keys
115
+ * (widget reads `sel`/`attr`/`val`/`mode`/`p`); the rest are provenance the
116
+ * Worker and the safety lookups need.
117
+ */
118
+ interface EngineFix {
119
+ sel: string;
120
+ /** The selector exactly as the producing engine emitted it, before any rewrite. Equal to `sel` when nothing rewrote it. */
121
+ originalSel: string;
122
+ attr: string;
123
+ val: string;
124
+ /** Served manifest mode. Engine fixes are plain attribute writes: 'attr'. */
125
+ mode: 'attr';
126
+ /** Page path the fix is scoped to (from the input URL), '/' when the input had none. */
127
+ p: string;
128
+ issueType: string;
129
+ wcag: string;
130
+ /** What the fix rests on: the finding's message and the element markup. */
131
+ evidence: string;
132
+ engineVersion: string;
133
+ }
134
+ /** One reported finding (scan()'s issue row). */
135
+ type EngineIssue = ScanIssue;
136
+
137
+ interface EngineStats {
138
+ engineVersion: string;
139
+ input: 'url' | 'snapshot';
140
+ ms: number;
141
+ deterministic: {
142
+ issues: number;
143
+ bySource: Record<string, number>;
144
+ engineWarnings: string[];
145
+ };
146
+ fixes: {
147
+ candidates: number;
148
+ emitted: number;
149
+ rejectedByValidator: number;
150
+ rejectedByHidingGuard: number;
151
+ rejected: Array<{
152
+ sel: string;
153
+ attr: string;
154
+ why: string;
155
+ }>;
156
+ };
157
+ /** No agent stage yet: always `ran: false`, and `skipped` says why. */
158
+ agent: {
159
+ ran: false;
160
+ skipped: string;
161
+ };
162
+ /** Storage operations the engine performed. Always 0: the package has no storage. */
163
+ io: {
164
+ dbRows: 0;
165
+ r2Gets: 0;
166
+ r2Puts: 0;
167
+ kvOps: 0;
168
+ };
169
+ /** Degraded stages (a failed load, a guard that could not run). A result with errors is partial. */
170
+ errors: string[];
171
+ /** Capabilities the injected Page lacked and what was skipped because of it. */
172
+ warnings: string[];
173
+ }
174
+ interface EngineResult {
175
+ url: string;
176
+ issues: EngineIssue[];
177
+ incomplete: EngineIssue[];
178
+ fixes: EngineFix[];
179
+ internal_bugs: InternalBug[];
180
+ stats: EngineStats;
181
+ /** Set when the target was a bot challenge, auth wall or HTTP error: nothing real was scanned. */
182
+ blocked?: {
183
+ reason: string;
184
+ signal?: string;
185
+ status?: number;
186
+ vendor?: string;
187
+ };
188
+ }
189
+ /** The served manifest wire keys, in order. */
190
+ declare const SERVED_FIX_KEYS: readonly ["sel", "attr", "val", "mode", "p"];
191
+
192
+ declare function runEngine(args: {
193
+ page: Page;
194
+ model?: Model;
195
+ input: EngineInput;
196
+ opts?: EngineOptions;
197
+ }): Promise<EngineResult>;
198
+
199
+ /**
200
+ * Driver adapters: the injected Page as core's PageLike, and a DOM snapshot
201
+ * loaded into it.
202
+ */
203
+
204
+ /**
205
+ * core's scan() runs on a PageLike (evaluate + addScriptTag + url). A driver
206
+ * without addScriptTag gets script content through a string `evaluate`. A
207
+ * script by URL is refused; no scan() stage loads one, so nothing is lost.
208
+ */
209
+ declare function toPageLike(page: Page, fallbackUrl: string): PageLike;
210
+ /**
211
+ * Load a DOM snapshot as the page's document, with no network fetch of the
212
+ * page itself. The HTML is parsed by DOMParser on about:blank; `<script>`
213
+ * elements of any namespace (HTML or SVG) and `<meta http-equiv=refresh>`
214
+ * are removed, so the snapshot cannot run its scripts or navigate the page on
215
+ * load.
216
+ *
217
+ * Inline event-handler attributes (`onclick`, `onerror`…) are KEPT: detectors
218
+ * read their text (missingRoles, linkWarnings, targetSize, timingAndInput). An
219
+ * `<img onerror>` can therefore still run when the snapshot loads; isolating
220
+ * that (no egress, or JavaScript off) is the driver's job, as stated on
221
+ * `Page`.
222
+ *
223
+ * With `baseUrl` and a driver `loadHtml`, the cleaned HTML is served at
224
+ * `baseUrl`, so `location` is the live page's (detectors compare links with
225
+ * `location.origin`/`pathname`). Without `loadHtml` the document stays on
226
+ * about:blank with a `<base href>`; the engine names that in `stats.warnings`.
227
+ */
228
+ declare function loadSnapshot(page: Page, html: string, baseUrl?: string): Promise<void>;
229
+ /** The Playwright page members the adapter reads (structural: no playwright import). */
230
+ interface PlaywrightPageLike {
231
+ goto(url: string, opts?: any): Promise<any>;
232
+ evaluate(fn: any, arg?: any): Promise<any>;
233
+ screenshot(opts?: any): Promise<Uint8Array>;
234
+ keyboard: {
235
+ press(key: string): Promise<void>;
236
+ };
237
+ waitForLoadState?(state: 'load', opts?: {
238
+ timeout?: number;
239
+ }): Promise<void>;
240
+ url(): string;
241
+ addScriptTag?(opts: {
242
+ content?: string;
243
+ url?: string;
244
+ }): Promise<unknown>;
245
+ route?(url: (u: URL) => boolean, handler: (route: any) => unknown): Promise<void>;
246
+ unroute?(url: (u: URL) => boolean, handler?: (route: any) => unknown): Promise<void>;
247
+ }
248
+ /**
249
+ * A Playwright page as the engine's Page. Playwright's evaluate takes one
250
+ * argument. With `route`, a snapshot is served at its own URL (`loadHtml`),
251
+ * so `location` is the live page's.
252
+ */
253
+ declare function fromPlaywright(p: PlaywrightPageLike): Page;
254
+
255
+ /**
256
+ * Fix Manifest Validator — Three-Layer Safety Architecture
257
+ *
258
+ * Every fix passes through three layers before reaching the DB:
259
+ *
260
+ * Layer 1: Structural validation (selector, attribute, value safety)
261
+ * Layer 2: Protected element check (don't touch third-party/admin/consent UI)
262
+ * Layer 3: Risk classification (additive vs modifier vs destructive)
263
+ *
264
+ * Layer 3 output feeds into the repository's status logic:
265
+ * - additive → normal confidence gate (>= 0.85 → approved)
266
+ * - modifier → always 'pending' (human review required)
267
+ * - destructive → rejected (unless source is 'manual')
268
+ */
269
+ interface ValidationResult {
270
+ ok: boolean;
271
+ reason?: string;
272
+ }
273
+ type FixRiskTier = 'additive' | 'modifier' | 'destructive';
274
+ interface FixClassification {
275
+ tier: FixRiskTier;
276
+ reason: string;
277
+ }
278
+ /**
279
+ * Validate a `role` attribute value.
280
+ * - case-sensitive: must be lowercase (rejects "Main", "Breadcrumb", "Navigation")
281
+ * - every space-separated token must be a real concrete ARIA role
282
+ * - empty / whitespace-only is invalid
283
+ */
284
+ declare function validateRoleValue(value: string): ValidationResult;
285
+ /**
286
+ * The one fix the `<html>` root takes: `lang` (WCAG 3.1.1 — the document
287
+ * language lives on `<html>` and nowhere else). afe1654ed rejected every bare
288
+ * `html` selector as structural hardening (its case: `html { style:
289
+ * filter:invert(1) }` repaints every page); `lang` writes no style, no markup
290
+ * and no behaviour, so it is the single exception. Every other attribute on
291
+ * `html` stays rejected. Exact attribute name, no case folding.
292
+ */
293
+ declare function isHtmlLangFix(selector: string | null | undefined, attribute: string | null | undefined): boolean;
294
+ /**
295
+ * Selector check for a fix whose attribute is known. `html` + `lang` passes
296
+ * (the root is one element, never protected UI); everything else takes
297
+ * validateSelector, where a bare `html` is still rejected.
298
+ */
299
+ declare function validateSelectorForAttribute(selector: string, attribute: string, protectedScope?: ProtectedScope): ValidationResult;
300
+ /**
301
+ * Would the browser's querySelectorAll parse this selector? (LEDGER row 78 —
302
+ * served selectors that throw are swallowed per fix by the widget and stay
303
+ * approved forever.) Two layers: the character/escape rules below refuse an
304
+ * unescaped `/`, `%`, `!` or a Tailwind `:variant` exactly as the browser
305
+ * does; then a real tokeniser (css-what) refuses unbalanced brackets, an
306
+ * unterminated string, an empty `:not()`, a dangling `#`, and `astParses`
307
+ * refuses what css-what tolerates but the browser does not (`#hero >`,
308
+ * `a||b`, `li:nth-child(2n+)`, `button::part()`).
309
+ */
310
+ declare function selectorParses(selector: string): boolean;
311
+ /** Which protected-element registry a writer consults. The scan path runs the
312
+ * full registry against a DOM it rendered; the ingest path keeps the narrower
313
+ * admin/overlay check because the full registry's SUBSTRING vendor patterns
314
+ * (crisp-, hubspot-) false-match attribute VALUES in live approved fixes
315
+ * (img[src=".../apple-cranberry-crisp-…jpg"], body.hubspot-disable-focus-styles). */
316
+ type ProtectedScope = 'full' | 'dead-region';
317
+ declare function validateSelector(selector: string, protectedScope?: ProtectedScope): ValidationResult;
318
+ declare function validateAttribute(attr: string): ValidationResult;
319
+ declare function validateTextValue(value: string, attribute?: string): ValidationResult;
320
+ /**
321
+ * Classify a fix into a risk tier:
322
+ * - additive: adds information that wasn't there (aria-label, alt, role, scope, etc.)
323
+ * - modifier: changes visibility/interactivity (aria-hidden, tabindex, style)
324
+ * - destructive: removes or empties functional attributes
325
+ */
326
+ declare function classifyFixRisk(fix: {
327
+ attribute: string;
328
+ value: string;
329
+ }): FixClassification;
330
+ /**
331
+ * `issueType` + `context` are optional: when the caller has the element's
332
+ * stored context (visible text), the WCAG 2.5.3 Label-in-Name rule runs on
333
+ * aria-label fixes (see labelInNameRule.ts). Without them the check is a no-op.
334
+ */
335
+ declare function validateFix(fix: {
336
+ selector: string;
337
+ attribute: string;
338
+ value: string;
339
+ issueType?: string | null;
340
+ context?: string | null;
341
+ }, protectedScope?: ProtectedScope): ValidationResult;
342
+
343
+ /**
344
+ * Hiding-hazard guard
345
+ * ───────────────────
346
+ * A single, detector-agnostic guard that prevents the scan from EVER emitting a
347
+ * fix that would remove an interactive control from the accessibility tree.
348
+ *
349
+ * The hazard: `aria-hidden="true"`, `role="presentation"`, or `role="none"` on
350
+ * an element that is itself interactive (a real `<a href>`, button, form
351
+ * control, interactive ARIA role, or `tabindex >= 0`) — or that CONTAINS a
352
+ * focusable descendant. Applied to, say, a "Sign up" CTA link, this hides the
353
+ * button from screen-reader users AND leaves it keyboard-focusable-but-invisible
354
+ * (a WCAG 4.1.2 hidden-focusable). That is the exact "overlay makes it worse"
355
+ * failure — the worst thing an accessibility product can ship.
356
+ *
357
+ * Several detectors CAN produce this in edge cases (decorative-icon size/class
358
+ * heuristics, sr-only-container keyboard-trap heuristics, adjacent-identical-
359
+ * link dedup). Rather than patch each one, this guard runs once over the merged
360
+ * output against the LIVE DOM, so it covers every detector — present and future
361
+ * — from one place. Correctly-decorative targets (non-interactive, no focusable
362
+ * descendants) are untouched.
363
+ */
364
+
365
+ /** The fields of a fix row the guard reads. */
366
+ interface HidingCandidate {
367
+ selector: string;
368
+ attribute?: string;
369
+ fixValue?: string;
370
+ }
371
+ /** Does this fix remove its target (and subtree) from the accessibility tree? */
372
+ declare function isHidingFix(attribute: string | undefined, fixValue: string | undefined): boolean;
373
+ /**
374
+ * Minimal element view — satisfied by a real DOM Element in the browser and by
375
+ * a fake in tests. The browser pass mirrors this rule's LOGIC (page.evaluate
376
+ * can't reference this import), but the role list + focusable selector are
377
+ * passed IN as arguments, so those constants have a single source of truth.
378
+ */
379
+ interface HazardElementView {
380
+ tagName: string;
381
+ getAttribute(name: string): string | null;
382
+ hasAttribute(name: string): boolean;
383
+ querySelector(sel: string): unknown | null;
384
+ querySelectorAll?(sel: string): ArrayLike<HazardElementView>;
385
+ textContent?: string | null;
386
+ isContentEditable?: boolean;
387
+ }
388
+ /**
389
+ * True when hiding this element would swallow a control: it is itself
390
+ * interactive/focusable, OR it contains a focusable descendant, OR it is or
391
+ * contains visually-hidden text (the sr-only pattern). That text exists only
392
+ * for screen-reader users, often as an icon's or link's only name; hiding it
393
+ * leaves them nothing (B13). `isVisuallyHidden` is the DOM fact (the live
394
+ * pass measures geometry and clipping); without it the third check is off.
395
+ */
396
+ declare function isInteractiveOrContainsFocusable(el: HazardElementView, isVisuallyHidden?: (e: HazardElementView) => boolean): boolean;
397
+ /**
398
+ * Drop every hiding fix whose live target is interactive or contains a
399
+ * focusable descendant. One page.evaluate over just the hiding candidates.
400
+ * Fails CLOSED for hiding fixes only: when the check cannot run, every hiding
401
+ * candidate is dropped (other fixes pass) and `onError` gets the reason.
402
+ */
403
+ declare function dropHazardousHidingFixes<T extends HidingCandidate>(page: Pick<Page, 'evaluate'>, issues: T[], onError?: (err: Error) => void): Promise<T[]>;
404
+
405
+ /**
406
+ * The subset of INTERACTIVE_ROLES that behave like a native form-control
407
+ * input (something a <fieldset> groups) — excludes navigational/structural
408
+ * roles (button, link, menuitem*, tab, option, treeitem, gridcell) that are
409
+ * interactive but not "controls" a fieldset legend would describe.
410
+ *
411
+ * fieldsetLegend.detector.ts's ARIA form-control count must inline an
412
+ * identical literal rather than import this — its detector function is
413
+ * serialized via page.evaluate/toString and cannot reference an imported
414
+ * binding at runtime (same constraint as the kernel). Only
415
+ * test/fieldsetLegend.formControlRoles.test.ts actually reads this export,
416
+ * to pin that inline literal against drift.
417
+ */
418
+ declare const FORM_CONTROL_ROLES: Set<string>;
419
+ /** Is the visible text a label the rule applies to? Shared with the vector. */
420
+ declare const isLabelText: (text: string) => boolean;
421
+ /** Does `value` contain `text` as a whole phrase (see labelContainsText in the kernel)? */
422
+ declare const labelContainsText: (value: string, text: string) => boolean;
423
+ /** Widget normalisation: trim, collapse whitespace. Case-folding happens at compare time. */
424
+ declare const normalizeVisibleText: (text: string) => string;
425
+ /**
426
+ * The same contract as browser-side JS for the scan detector, which runs under
427
+ * page.evaluate and cannot import (labelInName.detector.ts; injected by
428
+ * index.ts with the other helpers, declared in browser-globals.d.ts). Built
429
+ * from the very functions above, so there is one implementation, not a copy.
430
+ */
431
+ declare const LABEL_IN_NAME_JS: string;
432
+ interface LabelInNameInput {
433
+ attribute: string;
434
+ value: string;
435
+ issueType?: string | null;
436
+ /** The stored `fix_manifests.context` (JSON from the widget/Worker, or the
437
+ * scan detectors' `visible: … | aria-label: …` pipe string). */
438
+ context?: string | null;
439
+ }
440
+ interface LabelInNameResult {
441
+ ok: boolean;
442
+ reason?: string;
443
+ }
444
+ /** Lowercase, strip diacritics, collapse everything that is not a letter or digit to one space. */
445
+ declare function foldLabel(s: string): string;
446
+ /** Does `haystack` contain `needle` as a whole phrase after folding? */
447
+ declare function foldContains(haystack: string, needle: string): boolean;
448
+ interface ParsedContext {
449
+ tag: string;
450
+ role: string;
451
+ text: string;
452
+ /** The element's CURRENT accessible name as the detector saw it (Worker `currentAriaLabel` / `attrs['aria-label']`, scan-detector `aria-label:`); '' when the context carries none. */
453
+ ariaLabel: string;
454
+ }
455
+ /**
456
+ * The stored `fix_manifests.context` in its three generations: Worker JSON
457
+ * (optionally wrapped as `{original: "<json>", validationFailure}` by the AI
458
+ * validator), and the scan detectors' `visible: … | aria-label: …` pipe string.
459
+ */
460
+ declare function parseContext(context: string | null | undefined): ParsedContext;
461
+ /**
462
+ * Does this aria-label fix satisfy Label in Name? `ok:true` also when the rule
463
+ * does not apply (other attribute, non-interactive element, no visible text or
464
+ * symbol-only text) — callers treat only `ok:false` as a reject.
465
+ */
466
+ declare function labelInNameCheck(fix: LabelInNameInput): LabelInNameResult;
467
+
468
+ /**
469
+ * Page language detection for the missing `<html lang>` fix (WCAG 3.1.1).
470
+ *
471
+ * One file, two byte-identical copies: fix-manifest-api src/detectors/pageLanguage.ts
472
+ * and platform api/services/fixDetectors/pageLanguage.ts. A pinned-sha256 test
473
+ * in each repo (pageLanguage-drift) fails on any edit to one copy alone: change
474
+ * both, then update both pins in the same pair of PRs.
475
+ *
476
+ * The fix value is the page's REAL language, or no fix at all. A wrong `lang`
477
+ * makes the screen reader read the whole page with the wrong voice, which is
478
+ * worse than no `lang`. Every `html[lang]` row in prod carried "en" (72/72,
479
+ * 15 of them on German, Dutch, Spanish, French, Danish, Chinese and Italian
480
+ * pages, 2026-10-09) because the AI fill only saw the page title and domain.
481
+ *
482
+ * Inputs are facts the pipeline already holds at scan/ingest time: the page
483
+ * URL, the page text, and (when the caller can read them) the language the
484
+ * page declares in xml:lang / content-language / og:locale / body[lang] /
485
+ * a self-referencing hreflang. Pure function: no network, no AI, no per-site
486
+ * data. Runs once per page at detection time; the widget only applies the
487
+ * stored value.
488
+ *
489
+ * Decision:
490
+ * 1. Text decides when it is confident (function-word frequency for Latin
491
+ * script languages, the writing system for scripts only one language
492
+ * uses). Any declared or URL signal that disagrees → null.
493
+ * 2. Text not confident: declared signals that all agree decide, unless
494
+ * the text leans to another language or rules the declared one out.
495
+ * 3. A URL code (language subdomain or first path segment) decides only
496
+ * when the text leans the same way and does not rule it out.
497
+ * 4. Otherwise null — the caller emits NO row.
498
+ *
499
+ * Function-word evidence is measured against ALL page tokens, not only the
500
+ * tokens we recognise. A language's words make up 15–30% of the tokens of a
501
+ * real page in that language (home pages, nav included: 0.157–0.321 on 18
502
+ * live pages, 2026-10-10). A page in a language with no list here (Romanian,
503
+ * Indonesian, Turkish, Finnish, Hungarian) shares a few short words with the
504
+ * listed ones but reaches ≈0.10 at most for any of them, so it is never confident —
505
+ * and it rules out every listed language, so a declared "en" on such a page
506
+ * is not trusted either. Languages close enough to a listed one to clear the
507
+ * floor on their own (Catalan ~ Spanish, Galician ~ Portuguese, Afrikaans ~
508
+ * Dutch) carry their own list, so the words that tell them apart decide.
509
+ */
510
+ type LangSource = 'text' | 'declared' | 'url';
511
+ interface PageLanguageInput {
512
+ /** Page URL (or the canonical page key). */
513
+ url?: string | null;
514
+ /** Visible page text (any length; only the first 20k chars are read). */
515
+ text?: string | null;
516
+ /** Languages the page declares outside `<html lang>`: xml:lang, content-language, meta language, og:locale, body[lang], self hreflang. */
517
+ declared?: Array<string | null | undefined>;
518
+ }
519
+ interface PageLanguageResult {
520
+ /** Primary BCP-47 subtag, lowercase ("de", "nl", "zh"). */
521
+ lang: string;
522
+ source: LangSource;
523
+ }
524
+ interface TextVerdict {
525
+ /** Language the text points to, if any. */
526
+ lean: string | null;
527
+ /** True when the lean is strong enough to decide alone. */
528
+ confident: boolean;
529
+ /** Listed languages the text rules out: enough tokens, and their function words stay under MIN_SHARE of them. */
530
+ ruledOut: Set<string>;
531
+ }
532
+ /** Primary subtag of a BCP-47-ish tag ("de-DE", "de_DE", "zh-Hans" → "de"/"zh"); '' when it is not a language code. */
533
+ declare function primaryLanguage(tag: string | null | undefined): string;
534
+ /** Language code carried by the URL: a language subdomain ("fr.shop.de") or first path segment ("/de/", "/zh-hans/"). */
535
+ declare function languageFromUrl(url: string | null | undefined): string;
536
+ /** What the page text says about its language. */
537
+ declare function languageFromText(raw: string | null | undefined): TextVerdict;
538
+ /** The page's language, or null when nothing settles it (→ emit no fix). */
539
+ declare function detectPageLanguage(input: PageLanguageInput): PageLanguageResult | null;
540
+
541
+ /**
542
+ * Selector Quality Scoring
543
+ *
544
+ * Scores CSS selectors on specificity and stability. Fixes with low-quality
545
+ * selectors are either rejected or forced to pending based on score.
546
+ *
547
+ * A selector that can match unintended elements is dangerous — it can hide
548
+ * content, strip functionality, or inject wrong labels on the wrong elements.
549
+ */
550
+ interface SelectorQualityResult {
551
+ score: number;
552
+ reasons: string[];
553
+ }
554
+ /**
555
+ * Bare element tags that are too broad when used as the sole selector.
556
+ * `iframe` as a selector matches every iframe on the page.
557
+ *
558
+ * Exported so anything that judges a selector PART by this same universe
559
+ * (e.g. a comma-separated template row) imports this set instead of keeping
560
+ * a second hand-written copy that can silently drift (Pullfrog, #1025).
561
+ */
562
+ declare const BARE_TAGS: Set<string>;
563
+ declare function scoreSelectorQuality(selector: string): SelectorQualityResult;
564
+
565
+ export { BARE_TAGS, type EngineFix, type EngineInput, type EngineIssue, type EngineOptions, type EngineResult, type EngineStats, FORM_CONTROL_ROLES, type HazardElementView, InternalBug, LABEL_IN_NAME_JS, type Model, type ModelRequest, type ModelResponse, type Page, type PlaywrightPageLike, type ProtectedScope, SERVED_FIX_KEYS, classifyFixRisk, detectPageLanguage, dropHazardousHidingFixes, foldContains, foldLabel, fromPlaywright, isHidingFix, isHtmlLangFix, isInteractiveOrContainsFocusable, isLabelText, labelContainsText, labelInNameCheck, languageFromText, languageFromUrl, loadSnapshot, normalizeVisibleText, parseContext, primaryLanguage, runEngine, scoreSelectorQuality, selectorParses, toPageLike, validateAttribute, validateFix, validateRoleValue, validateSelector, validateSelectorForAttribute, validateTextValue };