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