From 6709e3f71267e7d9349f16660f1c0101385e1675 Mon Sep 17 00:00:00 2001
From: Piyush Vyas
Date: Mon, 4 May 2026 13:26:36 -0500
Subject: [PATCH 1/7] docs: add RC validation handoff
---
RC_VALIDATION.md | 197 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 197 insertions(+)
create mode 100644 RC_VALIDATION.md
diff --git a/RC_VALIDATION.md b/RC_VALIDATION.md
new file mode 100644
index 0000000..4a56516
--- /dev/null
+++ b/RC_VALIDATION.md
@@ -0,0 +1,197 @@
+# uSEID RC Validation Handoff
+
+This handoff is for validating a future `@pyyush/useid@1.0.0-rc.*` artifact before the stable `1.0.0` release. Do not treat this document as evidence that an RC exists; it is the checklist to run once an artifact is available.
+
+## Install Source Placeholders
+
+Fill these in before sending the handoff to a validator:
+
+- RC version: `<1.0.0-rc.N>`
+- Install source:
+ - npm dist-tag: `npm install @pyyush/useid@<1.0.0-rc.N>`
+ - tarball: `npm install /pyyush-useid-<1.0.0-rc.N>.tgz`
+ - GitHub source checkout, if no package artifact exists yet: `@`
+- Source commit: ``
+- Package provenance evidence: ``
+- Validation due date: ``
+
+## Validator Setup
+
+Use a clean Node project outside this repository:
+
+```bash
+mkdir useid-rc-smoke
+cd useid-rc-smoke
+npm init -y
+npm install
+node --version
+npm ls @pyyush/useid
+```
+
+Node must be `>=20.0.0`. Record the exact Node, npm, OS, and install source in the feedback section.
+
+## Import And Grounding Smoke
+
+Create `smoke-useid.mjs` in the clean sample project:
+
+```js
+import { buildUSEID, resolveUSEID, redactUSEID } from "@pyyush/useid";
+
+function ax(tree) {
+ return { tree, hash: "validator-ax", serialized: JSON.stringify(tree) };
+}
+
+function dom(snapshot = null) {
+ return { snapshot, hash: "validator-dom", serialized: JSON.stringify(snapshot) };
+}
+
+const pageUrl = "https://example.com/checkout";
+const baseTree = {
+ role: "WebArea",
+ name: "Checkout",
+ children: [
+ { role: "button", name: "Pay now" },
+ { role: "button", name: "Cancel" },
+ ],
+};
+
+const signature = buildUSEID({
+ domSnapshot: dom(),
+ accessibilitySnapshot: ax(baseTree),
+ elementIndex: 0,
+ pageUrl,
+});
+
+const strongFit = resolveUSEID({
+ signature,
+ domSnapshot: dom(),
+ accessibilitySnapshot: ax(baseTree),
+ pageUrl,
+ config: { threshold: 0.3 },
+});
+
+if (!strongFit.resolved) {
+ throw new Error(`expected strong-fit resolution, got ${strongFit.abstentionReason}`);
+}
+
+const bindingMismatch = resolveUSEID({
+ signature,
+ domSnapshot: dom(),
+ accessibilitySnapshot: ax(baseTree),
+ pageUrl: "https://evil.example/checkout",
+});
+
+if (bindingMismatch.resolved || bindingMismatch.abstentionReason !== "binding_mismatch") {
+ throw new Error("expected binding_mismatch abstention");
+}
+
+const noCandidates = resolveUSEID({
+ signature,
+ domSnapshot: dom(),
+ accessibilitySnapshot: ax({
+ role: "WebArea",
+ name: "Checkout",
+ children: [{ role: "link", name: "Help" }],
+ }),
+ pageUrl,
+});
+
+if (noCandidates.resolved || noCandidates.abstentionReason !== "no_candidates") {
+ throw new Error("expected no_candidates abstention");
+}
+
+const redacted = redactUSEID(signature);
+
+if (redacted.semantic.accessibleName === signature.semantic.accessibleName) {
+ throw new Error("expected redacted signature to remove the raw accessible name");
+}
+
+console.log({
+ version: signature.version,
+ selectorHint: strongFit.selectorHint,
+ confidence: strongFit.confidence,
+ abstentions: [bindingMismatch.abstentionReason, noCandidates.abstentionReason],
+ redactedHash: redacted.hash,
+});
+```
+
+Run it:
+
+```bash
+node smoke-useid.mjs
+```
+
+Pass criteria:
+
+- The package installs without local source checkout hacks.
+- `node smoke-useid.mjs` exits `0`.
+- The strong-fit case resolves with a button selector hint.
+- The cross-origin case abstains with `binding_mismatch`.
+- The no-same-role case abstains with `no_candidates`.
+- Redaction removes raw accessible names before logging/support use.
+
+Fail criteria:
+
+- Install fails from the provided RC source.
+- ESM import fails on Node `>=20`.
+- The strong-fit case abstains with the documented threshold.
+- The mismatch or no-candidate cases resolve instead of abstaining.
+- Raw accessible names remain in redacted signatures.
+
+## Grounding-Gate Example Review
+
+Review the repo-local example at `examples/grounding-gate.ts`. It is documentation/source-checkout material, not a published package entrypoint.
+
+Ask the validator to confirm:
+
+- The example keeps uSEID at the snapshot boundary and does not imply browser orchestration ownership.
+- The host integration captures DOM and accessibility snapshots before acting.
+- `resolveUSEID()` gates the action, and the host stops on every abstention reason.
+- Logged diagnostics avoid raw candidate names and raw explanation text by default.
+- The example is adaptable to their browser-agent or automation stack in under 30 minutes.
+
+## Real-World Validation Scenario
+
+Ask the validator to run one host-specific check in their own project or a representative sample:
+
+1. Capture a signature for a real button or link before UI churn.
+2. Re-run resolution after harmless wrapper/layout churn.
+3. Confirm the intended element resolves only when confidence is justified.
+4. Create an ambiguous or missing-target case.
+5. Confirm uSEID abstains and the host does not click/type/act.
+
+The validator does not need to adopt uSEID permanently. The goal is to prove install, import, grounding-gate semantics, and docs clarity.
+
+## Feedback Capture
+
+- Validator name or role:
+- Organization/project type:
+- Date:
+- RC version:
+- Install source:
+- Source commit:
+- Node/npm/OS:
+- Clean sample project path or host environment:
+- `npm ls @pyyush/useid` output:
+- `node smoke-useid.mjs` result:
+- Strong-fit scenario result:
+- Abstention/ambiguous scenario result:
+- Grounding-gate example path reviewed: `examples/grounding-gate.ts`
+- Docs clarity notes:
+- Security/privacy/package-content concerns:
+- API naming/signature concerns:
+- Performance or bundle-size concerns:
+- Release-blocking issues:
+- Non-blocking follow-ups:
+- Validator disposition: ``
+- uSEID owner disposition:
+- Follow-up owner:
+
+## Known Blockers
+
+- No `1.0.0-rc.*` artifact, tag, or npm publish exists yet.
+- A clean release branch/worktree is still required before cutting the RC.
+- npm registry credentials and provenance context have not been verified for the RC publish path.
+- At least one external or external-like validator must run the RC in their own project or representative sample.
+- The local performance budget was timing-sensitive during RC prep; watch extraction-budget evidence in CI and validator hardware notes.
+- This repository workspace currently has broad pre-existing dirty release-prep changes; do not treat local dirty state as release-branch readiness.
From 873039c9a0933a76c687edc122475de160498262 Mon Sep 17 00:00:00 2001
From: Piyush Vyas
Date: Mon, 4 May 2026 14:05:27 -0500
Subject: [PATCH 2/7] chore: checkpoint release hardening
---
.github/workflows/release.yml | 72 +-
.gitignore | 9 +
CHANGELOG.md | 47 ++
README.md | 156 ++++-
RELEASE_PLAN.md | 652 ++++++++++++++++++
examples/grounding-gate.ts | 159 +++++
package-lock.json | 224 +++---
package.json | 6 +-
src/__tests__/builder.test.ts | 35 +
src/__tests__/extractor.test.ts | 155 +++++
src/__tests__/fixtures/realistic-snapshots.ts | 319 +++++++++
src/__tests__/matcher.test.ts | 81 ++-
src/__tests__/performance-budget.test.ts | 183 +++++
src/__tests__/realistic-fixtures.test.ts | 157 +++++
src/__tests__/resolver.test.ts | 198 +++++-
src/__tests__/safety.test.ts | 100 +++
src/__tests__/types.test.ts | 115 +++
src/builder.ts | 13 +-
src/candidate.ts | 10 +-
src/extractor.ts | 180 ++++-
src/fingerprint.ts | 70 ++
src/index.ts | 6 +-
src/matcher.ts | 71 +-
src/resolver.ts | 74 +-
src/safety.ts | 30 +-
src/types.ts | 91 ++-
26 files changed, 2974 insertions(+), 239 deletions(-)
create mode 100644 CHANGELOG.md
create mode 100644 RELEASE_PLAN.md
create mode 100644 examples/grounding-gate.ts
create mode 100644 src/__tests__/fixtures/realistic-snapshots.ts
create mode 100644 src/__tests__/performance-budget.test.ts
create mode 100644 src/__tests__/realistic-fixtures.test.ts
create mode 100644 src/fingerprint.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fe2fc52..78b2f16 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,12 +5,80 @@ on:
tags:
- "v*"
+concurrency: ${{ github.workflow }}-${{ github.ref }}
+
permissions:
contents: write
+ id-token: write
jobs:
+ verify:
+ name: Verify (Node ${{ matrix.node-version }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: [20, 22]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Check repository hygiene
+ run: |
+ forbidden_files="$(git ls-files | grep -E '(^|/)(AGENTS\.md|CLAUDE\.md|\.DS_Store|\.dev-session|\.staff-engineer-state\.json|\.staff-engineer\.json)$|(^|/)(docs/plans|\.omx|\.bap|\.banners)(/|$)' || true)"
+
+ if [ -n "$forbidden_files" ]; then
+ echo "Tracked internal-only files found in public release tree."
+ printf '%s\n' "$forbidden_files"
+ exit 1
+ fi
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ registry-url: https://registry.npmjs.org
+ cache: npm
+
+ - run: npm ci
+ - name: Check tag and package version
+ run: |
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
+ PKG_VERSION="$(node -p "require('./package.json').version")"
+ test "$TAG_VERSION" = "$PKG_VERSION"
+ - name: Build package
+ run: npm run build
+ - name: Typecheck
+ run: npm run typecheck
+ - name: Run Vitest suite
+ run: npm test
+ - name: Run performance budget tests
+ run: npm test -- src/__tests__/performance-budget.test.ts
+ - name: Check npm audit clean state
+ run: npm audit --json
+ - name: Check package contents
+ run: |
+ pack_json="$(npm pack --dry-run --json)"
+ PACK_JSON="$pack_json" node - <<'NODE'
+ const [{ files }] = JSON.parse(process.env.PACK_JSON);
+ const forbidden = files
+ .map((file) => file.path)
+ .filter((path) =>
+ /(^|\/)(AGENTS\.md|CLAUDE\.md|AUDIT\.md|RELEASE_PLAN\.md|\.DS_Store|\.dev-session|\.staff-engineer-state\.json|\.staff-engineer\.json)$/.test(path) ||
+ /(^|\/)(docs\/plans|\.omx|\.bap|\.banners|coverage)(\/|$)/.test(path)
+ );
+
+ if (forbidden.length > 0) {
+ console.error("Internal-only files would be included in npm package:");
+ for (const path of forbidden) {
+ console.error(`- ${path}`);
+ }
+ process.exit(1);
+ }
+
+ console.log(`Package content check passed (${files.length} files).`);
+ NODE
+
publish:
runs-on: ubuntu-latest
+ needs: verify
steps:
- uses: actions/checkout@v4
@@ -22,9 +90,7 @@ jobs:
- run: npm ci
- run: npm run build
- - run: npm run typecheck
- - run: npm test
- - run: npm publish
+ - run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 4559f2a..bc7da9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,16 @@
node_modules
dist
+coverage
+*.tgz
.turbo
+.omx
+.bap
+.banners
+.DS_Store
.dev-session
.staff-engineer-state.json
.staff-engineer.json
+AUDIT.md
+AGENTS.md
+docs/plans/
CLAUDE.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..873572c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,47 @@
+# Changelog
+
+## [Unreleased] - 1.0.0 target
+
+`1.0.0` is the planned stable release target and is not published yet. The latest npm-published version is still `0.1.0`; the local package baseline in this branch is `0.2.0`.
+
+### Migration notes from published 0.1.0
+
+- Treat `ResolveResult` as the stable discriminated union: branch on `result.resolved` before reading success or failure fields.
+- Handle every stable abstention reason: `binding_mismatch`, `no_candidates`, `below_threshold`, and `ambiguous_match`.
+- Keep custom scoring weights normalized. `semantic + structural + spatial` must sum to `1`.
+- Expect stricter safe-abstention behavior for duplicate names, missing accessible-name evidence, role drift, incomplete layout evidence, and DOM/accessibility mismatches.
+- Do not use redacted signatures for later resolution. Redacted output is for logs and support bundles only.
+- Browser-harness alignment is docs/examples/design alignment only. There is no browser-harness dependency, backend, runtime bridge, or required browser runtime.
+
+### Security and observability guidance
+
+- Raw snapshots, signatures, candidate diagnostics, and explanations can contain page text, labels, accessible names, accessible descriptions, sibling tokens, and form labels.
+- Prefer `redactUSEID()` before logging or storing signatures outside short-lived debug paths.
+- Log stable operational fields such as `resolved`, `abstentionReason`, `confidence`, `scores`, `scoreGap`, candidate count, threshold, margin, and signature hash.
+- Avoid logging raw unresolved candidate names by default; use raw explanations only in controlled debug/support contexts.
+- Use redacted production log shapes for browser-harness grounding events. Keep raw `explainResolution()` output limited to local debugging or controlled support bundles.
+
+### Browser-harness alignment
+
+- Added docs for the snapshot boundary a browser harness must provide: current URL, DOM snapshot, accessibility snapshot, and optional frame path.
+- Added a grounding-gate example that resolves before click/fill-style actions and records confidence, score gap, abstention reason, and score bands without raw candidate names.
+- Clarified that README examples target the current branch or future release artifact, not the npm `latest` package while `latest` remains `0.1.0`.
+- Documented the current fixed `1024x768` spatial normalization limit so browser-harness adopters do not mistake it for a browser viewport matrix.
+- Confirmed the release scope is `learn-from` only: no browser-harness dependency, backend, runtime bridge, or required browser runtime.
+
+## [0.2.0] - 2026-04-02
+
+Local release baseline for the current branch. This version is not the npm `latest` at the time of the 1.0.0 planning work.
+
+### Added
+
+- clearer grounding-gate documentation and repo-local example
+- capture fingerprint support and richer resolution explainability
+- stronger extractor, matcher, resolver, and safety coverage
+- release verification and npm package metadata improvements
+
+### Changed
+
+- kept the product narrowly focused on safe grounding and abstention
+- improved candidate generation and structural scoring for repeated or shifted elements
+- clarified current support matrix and honest abstention cases
diff --git a/README.md b/README.md
index 4b26a8e..cfc8af2 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,16 @@
-**Your selectors break. uSEID doesn't.**
+**uSEID is the grounding gate for browser agents: resolve when the target is justified, abstain when it is not.**
Browser agents and E2E tests fail when the UI changes. A developer renames a CSS class, wraps a button in a new `
`, or ships an A/B variant — and suddenly your carefully crafted selectors target the wrong element. Or nothing at all.
-uSEID solves this by identifying elements the way a human would: by what they *are* (a "Submit" button), where they *sit* (inside the checkout form), and where they *appear* (bottom-right of the main content). When the DOM reshuffles but the element is still there, uSEID finds it. When it's genuinely gone or ambiguous, uSEID tells you — it never silently acts on the wrong thing.
+uSEID solves the grounding problem by identifying elements from portable snapshot evidence: what they *are* (role + accessible name), where they *sit* (structural context), and where they *appear* (spatial context). When supported signals still point strongly to the same element, uSEID can resolve it. When the evidence is weak, conflicting, or out of scope, it abstains clearly instead of nudging a browser agent toward the wrong target.
+
+uSEID is intentionally narrow:
+
+- **It is:** a portable element identity and safe-resolution layer.
+- **It is not:** a browser automation runtime, replay engine, or generic agent framework.
## How It Works
@@ -36,6 +41,8 @@ uSEID builds a **portable signature** from three signals:
| **Structural** | Ancestor roles, sibling labels, form associations | Survives wrapper changes |
| **Spatial** | Bounding box position | Catches layout-only changes |
+The signature hash is a **capture fingerprint**, not a promise of permanent identity. It includes binding and structural context so duplicate same-name elements on one page do not collapse into the same fingerprint.
+
## Install
```bash
@@ -44,10 +51,27 @@ npm install @pyyush/useid
Zero config. One dependency (zod). Works with any Node.js 20+ project.
+## Release Status
+
+The npm latest version is verified as `0.1.0` as of May 4, 2026. This branch is preparing the local `0.2.0` baseline on the path to a stable `1.0.0` release; `1.0.0` is not published yet. The examples below describe the current branch API and the intended stable contract unless the changelog says otherwise. Until a newer package is published, use this repository branch or a dry-run package artifact to try these examples.
+
+For a first working check in under five minutes against this branch: capture one DOM snapshot plus one accessibility snapshot from your browser tool, choose the intended element from `extractElements()`, call `buildUSEID()`, then call `resolveUSEID()` before taking the browser action.
+
+## Migration Notes For The 1.0.0 Target
+
+If you are moving from the published `0.1.0` package toward the planned `1.0.0` contract:
+
+- Branch on `result.resolved` before reading success or failure fields.
+- Handle all stable abstention reasons: `binding_mismatch`, `no_candidates`, `below_threshold`, and `ambiguous_match`.
+- Keep custom scoring weights normalized. `semantic + structural + spatial` must equal `1`.
+- Expect stricter abstention for duplicate same-role targets, missing accessible-name evidence, role drift, incomplete layout evidence, and DOM/accessibility mismatches.
+- Treat redacted signatures as log/support artifacts only. They are intentionally not usable for later resolution.
+- Keep browser-harness adoption at the snapshot boundary unless a separate integration project explicitly adds a bridge.
+
## Quick Start
```typescript
-import { buildUSEID, resolveUSEID } from "@pyyush/useid";
+import { buildUSEID, extractElements, resolveUSEID } from "@pyyush/useid";
// Capture snapshots from your browser automation tool
const domSnapshot = {
@@ -61,10 +85,21 @@ const a11ySnapshot = {
};
// Build a signature for the "Add to Cart" button
+const elements = extractElements(domSnapshot, a11ySnapshot);
+const elementIndex = elements.findIndex(
+ (element) =>
+ element.role === "button" &&
+ element.accessibleName.toLowerCase() === "add to cart"
+);
+
+if (elementIndex === -1) {
+ throw new Error("Could not find the Add to Cart button in the captured snapshots");
+}
+
const signature = buildUSEID({
domSnapshot,
accessibilitySnapshot: a11ySnapshot,
- elementIndex: 0, // Index in the extracted element list
+ elementIndex,
pageUrl: "https://shop.example.com/product/42",
});
@@ -81,12 +116,34 @@ const result = resolveUSEID({
if (result.resolved) {
console.log(result.selectorHint); // role=button[name="add to cart"]
console.log(result.confidence); // 0.94
+ console.log(result.scores); // { semantic, structural, spatial }
} else {
console.log(result.abstentionReason); // "below_threshold"
console.log(result.explanation); // human-readable why
}
```
+## Grounding Gate Pattern
+
+uSEID is meant to sit in front of an execution layer:
+
+```typescript
+const result = resolveUSEID({ signature, domSnapshot, accessibilitySnapshot, pageUrl });
+
+if (!result.resolved) {
+ // Stop the action. Ask for human review, refresh the capture, or fall back
+ // to a safer recovery path in your own system.
+ throw new Error(`uSEID abstained: ${result.abstentionReason} - ${result.explanation}`);
+}
+
+// Only act after the grounding gate resolves.
+await executor.click(result.selectorHint);
+```
+
+That contract is the point of the library: **wrong element is worse than no element.**
+
+See `examples/grounding-gate.ts` for a repo-local copy/adapt example of the same pattern. It is documentation, not a published package entrypoint.
+
## Safety: Wrong Element Is Worse Than No Element
Most selector strategies fail silently — they click *something*, just not the right thing. uSEID's safety gate ensures that doesn't happen:
@@ -94,11 +151,30 @@ Most selector strategies fail silently — they click *something*, just not the
| When this happens... | uSEID does this | Why |
|---------------------|----------------|-----|
| Page URL doesn't match signature | Abstains (`binding_mismatch`) | Prevents cross-page false matches |
-| No elements match the expected role | Abstains (`no_candidates`) | Element was removed |
+| No elements share the expected role | Abstains (`no_candidates`) | Avoids widening to unrelated element types |
| Best match scores below 0.85 | Abstains (`below_threshold`) | Not confident enough |
-| Two candidates score too close | Abstains (`ambiguous_match`) | Can't tell which is right |
+| Two candidates score too close | Abstains (`ambiguous_match`) | Near-ties are still unsafe |
+
+Every abstention comes with an `explanation` string and a ranked `candidates` list with per-signal scoring context so you can debug or escalate to a human. Successful resolutions also return per-signal `scores` and an optional `scoreGap` to show how clearly the winner separated from the runner-up.
-Every abstention comes with an `explanation` string and a ranked `candidates` list so you can debug or escalate to a human.
+## Observability And Debugging
+
+For production traces, log stable, low-risk fields first:
+
+- `resolved`
+- `abstentionReason` when unresolved
+- `confidence`, `scores`, and `scoreGap` when resolved
+- configured `threshold`, `marginConstraint`, and `weights`
+- signature `hash`, page origin/path, and frame depth
+- candidate count and top candidate score bands, not raw candidate names by default
+
+`scoreGap` is present on resolved results when there is a runner-up candidate; if it is absent, only one same-role candidate reached the safety gate. Track missing gaps separately from small gaps.
+
+The stable abstention reasons are `binding_mismatch`, `no_candidates`, `below_threshold`, and `ambiguous_match`. Treat these as metric dimensions and alert on changes in abstention rate rather than forcing a fallback action.
+
+Use `explainResolution(result)` for local debugging or redacted support bundles. The explanation and unresolved `candidates` list can contain accessible names from the page, so avoid shipping raw explanations into long-retention logs unless your product policy allows that data.
+
+For production logs, do not call `explainResolution(result)` on raw unresolved results by default. Prefer a redacted shape with `abstentionReason`, candidate count, score bands, and a generic explanation. If you need a support bundle, redact candidate `accessibleName`, `selectorHint`, and any top-level explanation text first; `examples/grounding-gate.ts` shows that pattern.
## Configurable Scoring
@@ -122,9 +198,11 @@ resolveUSEID({
});
```
-## Privacy Built In
+## Security And Privacy
-Element signatures can contain accessible names from form labels. For logging or storage:
+Element signatures are derived from page snapshots. That means raw signatures, explanations, and candidate diagnostics can include user-visible text, accessible names, accessible descriptions, sibling labels, and form labels. Treat them as potentially sensitive application data.
+
+For logging or storage:
```typescript
import { redactUSEID } from "@pyyush/useid";
@@ -134,6 +212,14 @@ const safe = redactUSEID(signature);
// Safe to log. NOT resolvable after redaction (by design).
```
+Guidance:
+
+- Store raw signatures only where you would store the underlying page text.
+- Prefer redacted signatures in logs, analytics, support bundles, and test artifacts.
+- Do not expect `redactUSEID()` output to resolve later; redaction intentionally removes grounding signal.
+- Candidate diagnostics are useful for debugging but can expose nearby labels. Log counts, reasons, scores, and redacted explanations by default.
+- Avoid sending raw snapshots, signatures, or candidate lists to model prompts or external observability tools unless your data-handling policy permits it.
+
## Bring Your Own Automation
uSEID is framework-agnostic. It accepts two minimal interfaces:
@@ -150,16 +236,58 @@ interface AccessibilitySnapshotResult {
No Playwright dependency. No CDP dependency. If your tool can produce a DOM tree and an accessibility tree, uSEID works with it.
-## What Works Today (v0.1.0)
+## Snapshot Boundary
+
+To drive uSEID from a browser harness or any CDP-capable host, capture the boundary data outside uSEID and pass it in:
+
+| Boundary input | Required shape | Notes |
+|----------------|----------------|-------|
+| Page URL | Current page URL string | Must match signature origin and path. |
+| DOM snapshot | `DOMSnapshot.captureSnapshot` response | Use `includeDOMRects: true`; layout bounds improve spatial confidence. |
+| Accessibility snapshot | Accessibility tree with roles and names | Use a full tree when possible, such as Playwright `interestingOnly: false`. |
+| Frame path | `FramePathEntry[]` for iframe targets | Required when the signature was built inside a frame. |
+
+Spatial scoring currently normalizes layout distance against a fixed `1024x768` viewport model. Keep viewport sizes stable across captures when spatial evidence matters, and treat spatial score as disambiguation evidence rather than a browser-viewport support claim.
+
+Strong-fit cases have the same binding, same role, stable accessible name, enough structural context, usable layout evidence, and a score gap above the margin. Abstain cases include binding mismatch, missing same-role candidates, weak evidence below threshold, near-tied duplicates, DOM/a11y mismatch, closed shadow DOM without internal layout evidence, and cross-origin frames unless the caller can provide separately bound snapshots.
+
+## Browser-Harness Learn-From Interop
+
+Decision: `learn-from`. uSEID does not add a browser-harness dependency, backend, runtime bridge, or required browser runtime. A formal bridge would change scope and needs human confirmation before implementation.
+
+Use this mapping when a browser-harness-style agent wants safe grounding:
+
+| Browser-harness step | uSEID-safe step |
+|----------------------|-----------------|
+| Observe page/frame | Capture DOM + accessibility snapshots and current page URL. |
+| Select target | Build or load a `USEIDSignature` for the intended element. |
+| Before click/fill/hover | Call `resolveUSEID()` with fresh snapshots and frame path. |
+| Resolved | Execute the harness action against `selectorHint` and record confidence/score gap. |
+| Abstained | Do not act. Record the abstention reason, refresh capture, ask for review, or choose a product-specific safe recovery path. |
+
+See `examples/grounding-gate.ts` for a browser-harness-facing example that resolves a target only after the uSEID confidence and abstention gate passes.
+
+## What Works Today (current branch `0.2.0` baseline)
| | Supported | Behavior |
|-|-----------|----------|
| Chromium | Yes | Full CDP snapshot support |
| Main frame | Yes | Default |
-| Same-origin iframes | Yes | Via `framePath` binding |
-| Cross-origin iframes | No | Abstains with explanation |
+| Same-origin iframes | Yes | When the caller captures the iframe snapshots and passes the correct `framePath` |
+| Cross-origin iframes | No | Out of scope unless the caller can provide separate bound snapshots |
| Open shadow DOM | Yes | Flattened by CDP DOMSnapshot |
-| Closed shadow DOM | No | Abstains |
+| Closed shadow DOM | No | Abstains when internal name/layout evidence is unavailable |
+
+## Support Limits And Honest Abstention Cases
+
+Expect abstentions, not heroics, when:
+
+- the expected role is gone from the page
+- two candidates remain near-tied after scoring
+- accessible names are missing or unstable
+- the caller provides incomplete or mismatched frame bindings
+- DOM and accessibility snapshots disagree too much to ground safely
+- the host surface falls outside the current Chromium + CDP snapshot model
## Full API
@@ -167,7 +295,7 @@ No Playwright dependency. No CDP dependency. If your tool can produce a DOM tree
|----------|---------|
| `buildUSEID(opts)` | Build a portable signature from snapshots |
| `resolveUSEID(opts)` | Resolve a signature against current snapshots |
-| `compareUSEID(a, b)` | Compare two signatures (0, 0.5, or 1) |
+| `compareUSEID(a, b)` | Compare two signatures conservatively (0, 0.5, or 1) |
| `explainResolution(result)` | Human-readable explanation |
| `redactUSEID(signature)` | Strip PII for safe logging |
diff --git a/RELEASE_PLAN.md b/RELEASE_PLAN.md
new file mode 100644
index 0000000..55400e9
--- /dev/null
+++ b/RELEASE_PLAN.md
@@ -0,0 +1,652 @@
+# uSEID 1.0.0 Release Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. This plan is Phase 2 only; do not begin implementation from this document until Phase 3 is explicitly authorized.
+
+**Goal:** Ship `@pyyush/useid@1.0.0` as a stable, secure grounding gate for browser agents: build portable signatures, resolve only when confidence is justified, and abstain clearly when it is not.
+
+**Architecture:** Keep uSEID narrow. The package remains a TypeScript/Node library with framework-agnostic DOM and accessibility snapshot inputs, a signature builder, candidate/scoring pipeline, safety gate, and explanation/redaction surfaces. Browser-harness alignment is docs/examples/design alignment only; no browser-harness dependency, backend, required runtime, or bridge is in scope without human confirmation.
+
+**Tech Stack:** TypeScript, Zod, tsup, Vitest, npm lockfile workflow, GitHub Actions on Node 20/22.
+
+---
+
+## Target Version
+
+Target release: `1.0.0`.
+
+Semver justification:
+
+- npm latest verified during this phase: `@pyyush/useid@0.1.0`.
+- Local audit baseline: `package.json` is `0.2.0`, unpublished as latest; `package-lock.json` root still has name drift.
+- The mission is a stable, secure, valuable major release, and the audit identifies public API decisions that should be finalized before a stable contract.
+- Pre-1.0 changes may break or tighten API semantics. Once result schemas, abstention reasons, config behavior, support claims, and release distribution are stable, the correct semver target is `1.0.0`.
+
+If the orchestrator elects an interim publish before stable-major, use `0.2.0` or a later `0.x` version. This plan is for the stable major release.
+
+## Cycle Estimate Model
+
+One cycle means one focused implementation pass with tests, docs where relevant, and local verification. Larger tasks may need multiple cycles because they touch API, tests, docs, and release workflow together.
+
+## Ordered Task List
+
+| Order | Task | DoD Tags | Est. Cycles | Depends On | Deliverable |
+|---:|---|---|---:|---|---|
+| 1 | Reconcile release baseline and dirty worktree ownership | Repo hygiene, Release & distribution | 1 | none | Complete: ownership map recorded 2026-05-04 |
+| 2 | Fix redaction privacy bug and diagnostics sensitivity | Security, Stability, Observability | 1 | 1 | Complete: redacted hash no longer uses stripped context fields |
+| 3 | Lock stable result and config contracts | API quality, Stability, Observability | 2 | 1 | Complete: enum reasons, bounded scores/confidence, normalized weights |
+| 4 | Harden extraction and matching behavior for safe abstention | Value, Stability, Tests | 2 | 3 | Complete: safer DOM/a11y matching limits, role drift behavior, ambiguity fixtures |
+| 5 | Add realistic snapshot fixture coverage | Value, Tests, Browser-harness research | 2 | 3, 4 | Complete: fixtures for UI churn, duplicates, missing role/name, iframe, shadow limits |
+| 6 | Add performance budgets and hot-path checks | Performance, Tests | 1 | 4, 5 | Complete: Vitest budgets for large extraction, scoring, and built bundle size |
+| 7 | Resolve npm audit and package metadata drift | Security, Release & distribution, Repo hygiene | 1 | 1 | Complete: audit clean, lockfile package name aligned, package contents verified |
+| 8 | Update public docs, examples, and browser-harness-facing guides | Docs, Value, Observability, Browser-harness research | 2 | 2, 3, 5 | README/API/migration/privacy/support docs plus browser-harness alignment docs |
+| 9 | Tighten CI/release hygiene without locking browser matrix | Repo hygiene, Release & distribution, Tests | 1 | 5, 7 | Release checks catch internal files and run required gates on Node 20/22 |
+| 10 | Run RC gate and external validation | Release & distribution, Value, Stability, Docs | 1 | 2-9 | Release candidate evidence and external-user feedback |
+| 11 | Final release gate and publish | Release & distribution, Security, Repo hygiene | 1 | 10 | `v1.0.0` tag, npm publish, GitHub release |
+
+Total estimate: 15 cycles.
+
+## Task Details
+
+### Task 1: Reconcile Release Baseline And Dirty Worktree Ownership
+
+**DoD:** Repo hygiene, Release & distribution
+
+**Status:** Complete for Phase 3 Task 1 on 2026-05-04. This completes the planning/ownership reconciliation only; it does not authorize broad cleanup or source changes outside the next assigned task.
+
+**Files likely touched in Phase 3:** no product files required unless orchestrator assigns ownership; current dirty files include package files, workflows, source, tests, README, `CHANGELOG.md`, `examples/`, and `src/fingerprint.ts`.
+
+**Plan:**
+
+- Capture fresh `git status --short`.
+- Identify which dirty files are pre-existing implementation work versus release-plan work.
+- Ask the orchestrator before modifying any file already dirty from another worker.
+- Keep `AUDIT.md` and `RELEASE_PLAN.md` as planning artifacts unless the orchestrator says otherwise.
+- Do not revert, overwrite, or clean up unrelated edits.
+
+**Exit criteria:**
+
+- Phase 3 starts with a clear file ownership map.
+- No unowned dirty work is overwritten.
+
+**Fresh status captured:**
+
+```text
+ M .github/workflows/release.yml
+ M .gitignore
+ M README.md
+ M package-lock.json
+ M package.json
+ M src/__tests__/builder.test.ts
+ M src/__tests__/extractor.test.ts
+ M src/__tests__/matcher.test.ts
+ M src/__tests__/resolver.test.ts
+ M src/__tests__/safety.test.ts
+ M src/__tests__/types.test.ts
+ M src/builder.ts
+ M src/candidate.ts
+ M src/extractor.ts
+ M src/index.ts
+ M src/matcher.ts
+ M src/resolver.ts
+ M src/safety.ts
+ M src/types.ts
+?? AUDIT.md
+?? CHANGELOG.md
+?? RELEASE_PLAN.md
+?? examples/
+?? src/fingerprint.ts
+```
+
+**Diff metadata inspected:**
+
+- Tracked dirty files: 19 files, 677 insertions, 107 deletions.
+- Untracked files: `AUDIT.md`, `CHANGELOG.md`, `RELEASE_PLAN.md`, `examples/grounding-gate.ts`, `src/fingerprint.ts`.
+- Task 2-relevant dirty files inspected directly: `src/resolver.ts`, `src/__tests__/resolver.test.ts`, `src/fingerprint.ts`.
+- Later-task dirty metadata inspected for package/workflow/docs/source/test groups.
+
+**Ownership map:**
+
+| Category | Files | Ownership rule |
+|---|---|---|
+| Planning files owned by this agent | `RELEASE_PLAN.md`; prior audit artifact `AUDIT.md` | May update `RELEASE_PLAN.md` for task tracking. Do not edit `AUDIT.md` unless explicitly asked. |
+| Needed for Task 2 redaction privacy fix | `src/resolver.ts`, `src/fingerprint.ts`, `src/__tests__/resolver.test.ts` | These files are already dirty/untracked from pre-existing work. Task 2 may patch them additively and narrowly, preserving existing changes. Do not rewrite or revert current hunks. |
+| Possible Task 2 docs follow-up, not for the next unit unless requested | `README.md` | Dirty and unowned. Do not touch during the Task 2 code/test fix unless the user explicitly includes docs in that unit. |
+| Later API contract tasks | `src/types.ts`, `src/constants.ts`, `src/matcher.ts`, `src/safety.ts`, `src/resolver.ts`, `src/index.ts`, `src/__tests__/types.test.ts`, `src/__tests__/matcher.test.ts`, `src/__tests__/safety.test.ts`, `src/__tests__/resolver.test.ts` | Relevant to Tasks 3-4. Treat current edits as pre-existing; modify only when the corresponding task is authorized. |
+| Later extraction/fixture tasks | `src/extractor.ts`, `src/builder.ts`, `src/candidate.ts`, `src/__tests__/builder.test.ts`, `src/__tests__/extractor.test.ts`, optional future fixtures | Relevant to Tasks 4-5. Do not touch during Task 2. |
+| Later release/security/hygiene tasks | `package.json`, `package-lock.json`, `.github/workflows/release.yml`, `.gitignore` | Relevant to Tasks 7 and 9. Do not touch until toolchain/package/workflow changes are explicitly authorized. |
+| Later public docs/examples tasks | `README.md`, `CHANGELOG.md`, `examples/grounding-gate.ts` | Relevant to Task 8. Do not touch during Task 2 unless that task is widened. |
+| Do not touch yet | all dirty files not explicitly assigned in the current task | Preserve as pre-existing/unowned changes. |
+
+**Commit constraints:**
+
+- Do not commit during Task 1.
+- Future commits must stage only files authorized for the active task.
+- Do not sweep unrelated dirty files into a commit.
+- Do not clean, revert, reformat, or regenerate pre-existing dirty files unless the active task explicitly requires it.
+- For Task 2, the safe commit scope is expected to be `src/resolver.ts`, `src/fingerprint.ts`, and `src/__tests__/resolver.test.ts` only, plus `RELEASE_PLAN.md` if task status is updated.
+
+**Planning-only verification:**
+
+- `npm test`: passed, 7 test files and 124 tests.
+- `npm audit --json`: failed with the known 2 vulnerabilities: high transitive `vite` and moderate transitive `postcss`. This remains a Task 7 release/security blocker.
+
+### Task 2: Fix Redaction Privacy Bug And Diagnostics Sensitivity
+
+**DoD:** Security, Stability, Observability
+
+**Status:** Complete for Phase 3 Task 2 on 2026-05-04. The implementation was limited to `src/fingerprint.ts` and `src/__tests__/resolver.test.ts`; diagnostics docs remain planned for Task 8.
+
+**Files likely touched in Phase 3:**
+
+- `src/resolver.ts`
+- `src/fingerprint.ts`
+- `src/__tests__/resolver.test.ts`
+- README/privacy docs if docs are in scope for that implementation unit
+
+**Plan:**
+
+- Add a failing test proving `redactUSEID()` recomputes hash without original `formAssociation`.
+- Fix the override path so explicit redaction does not fall back through `??` to original form labels.
+- Add test coverage for accessible description, sibling tokens, and form association redaction together.
+- Decide whether unresolved candidate diagnostics need a redacted display helper or explicit docs warning.
+
+**Exit criteria:**
+
+- Redacted signatures do not encode original accessible names, descriptions, sibling tokens, or form associations.
+- Tests fail before the fix and pass after the fix.
+
+**Verification:**
+
+- RED regression: `npm test -- src/__tests__/resolver.test.ts -t "should compute redacted hash without redacted context fields"` failed before the fix because redacted hashes still differed when only stripped context fields differed.
+- GREEN regression: the same targeted test passed after the fix.
+- `npm run build`: passed.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 7 test files and 125 tests.
+- `npm audit --json`: failed with the pre-existing 2 vulnerabilities: high transitive `vite` and moderate transitive `postcss`. This remains assigned to Task 7.
+
+### Task 3: Lock Stable Result And Config Contracts
+
+**DoD:** API quality, Stability, Observability
+
+**Status:** Complete for Phase 3 Task 3 on 2026-05-04. Stable abstention reasons are now represented by a schema/type, public result scores/confidence are schema-bounded to 0-1, and custom scoring weights are rejected unless they sum to 1.
+
+**Files likely touched in Phase 3:**
+
+- `src/types.ts`
+- `src/constants.ts`
+- `src/matcher.ts`
+- `src/safety.ts`
+- `src/resolver.ts`
+- `src/index.ts`
+- relevant tests
+
+**Plan:**
+
+- Replace unconstrained `abstentionReason: string` with a stable enum type/schema.
+- Enforce or normalize confidence and score ranges so public results stay within the documented 0-1 contract.
+- Decide and document custom-weight behavior: reject non-normalized weights or normalize them consistently.
+- Decide which lower-level exports are stable public API for `1.0.0`.
+- Add schema and behavior tests for exhaustive result handling and invalid config.
+
+**Exit criteria:**
+
+- Downstream consumers can exhaustively switch on abstention reasons.
+- Invalid config fails predictably.
+- Scores and confidence cannot exceed documented ranges.
+
+**Verification:**
+
+- RED contract tests: focused `types`, `matcher`, and `safety` tests failed before implementation on missing abstention reason schema/export, unbounded result schemas, and accepted non-normalized weights.
+- GREEN contract tests: focused `types`, `matcher`, and `safety` tests passed after implementation.
+- `npm run build`: passed.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 7 test files and 136 tests.
+- `npm audit --json`: failed with the pre-existing 2 vulnerabilities: high transitive `vite` and moderate transitive `postcss`. This remains assigned to Task 7.
+
+### Task 4: Harden Extraction And Matching Behavior For Safe Abstention
+
+**DoD:** Value, Stability, Tests
+
+**Status:** Complete for Phase 3 Task 4 on 2026-05-04. Same-role candidate generation remains the safe default; missing accessible-name evidence no longer earns semantic confidence, and DOM geometry is not borrowed from same-tag DOM nodes when text/label evidence conflicts with the accessibility name. The fixed 1024x768 spatial normalization remains a tested limitation to document in Task 8 rather than a locked browser/toolchain decision.
+
+**Files likely touched in Phase 3:**
+
+- `src/extractor.ts`
+- `src/candidate.ts`
+- `src/matcher.ts`
+- `src/safety.ts`
+- relevant tests
+
+**Plan:**
+
+- Preserve same-role candidate generation as the safe default.
+- Add focused tests for role drift, missing accessible names, duplicate names, incomplete DOM layout, and DOM/a11y mismatch.
+- Improve extractor matching where safe, but prefer explicit abstention over broad fallback behavior.
+- Review hardcoded viewport behavior and either make it configurable or document and test the current limit honestly.
+
+**Exit criteria:**
+
+- Ambiguous or weak evidence abstains with a clear reason.
+- Matching improvements do not relax the "wrong element is worse than no element" rule.
+
+**Verification:**
+
+- RED regression: focused extractor/matcher/safety tests failed before implementation on DOM/a11y text mismatch geometry assignment and missing accessible-name semantic over-scoring.
+- GREEN regression: `npm test -- src/__tests__/extractor.test.ts src/__tests__/matcher.test.ts src/__tests__/safety.test.ts` passed, 3 test files and 57 tests.
+- `npm run build`: passed.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 7 test files and 143 tests.
+- `npm audit --json`: failed with the pre-existing 2 vulnerabilities: high transitive `vite` and moderate transitive `postcss`. This remains assigned to Task 7.
+
+### Task 5: Add Realistic Snapshot Fixture Coverage
+
+**DoD:** Value, Tests, Browser-harness research
+
+**Status:** Complete for Phase 3 Task 5 on 2026-05-04. Added fixture-backed coverage for wrapper/layout churn, duplicate same-role same-name ambiguity, missing role/name abstention, same-origin iframe `framePath` binding, representable open shadow DOM support, and closed shadow DOM abstention when DOM/layout evidence is unavailable. No production source changes were required.
+
+**Files likely touched in Phase 3:**
+
+- `src/__tests__/`
+- optional fixture directory under `src/__tests__/fixtures/` or `test/fixtures/`
+- README/support docs if docs are in scope for that implementation unit
+
+**Plan:**
+
+- Add fixtures for same target under wrapper/layout churn.
+- Add duplicate same-role/same-name fixture that must abstain or require sufficient margin.
+- Add missing role/name fixture that must abstain.
+- Add same-origin iframe binding fixture using `framePath`.
+- Add open shadow DOM support fixture if representable from current snapshot model.
+- Add closed shadow DOM limitation fixture or docs-backed abstention case.
+
+**Exit criteria:**
+
+- README support claims are backed by tests or explicitly labeled as limits.
+- Fixture names map clearly to support-matrix rows.
+
+**Verification:**
+
+- Fixture regression: `npm test -- src/__tests__/realistic-fixtures.test.ts` passed, 1 test file and 6 tests.
+- `npm run build`: passed.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 8 test files and 149 tests.
+- `npm audit --json`: failed with the pre-existing 2 vulnerabilities: high transitive `vite` and moderate transitive `postcss`. This remains assigned to Task 7.
+
+### Task 6: Add Performance Budgets And Hot-Path Checks
+
+**DoD:** Performance, Tests
+
+**Status:** Complete for Phase 3 Task 6 on 2026-05-04. Added deterministic Vitest budget coverage for large DOM/accessibility extraction, many same-role candidate scoring, and built bundle artifact size. Initial extraction budget failed before implementation at 600 controls, which justified a narrow extractor optimization: DOM nodes are now indexed by tag plus exact text/label name while duplicate exact-name DOM matches remain ambiguous.
+
+**Files likely touched in Phase 3:**
+
+- benchmark or test file under the repo's existing test structure
+- `package.json` only if the implementation unit permits adding a script
+- README/performance docs if docs are in scope
+
+**Plan:**
+
+- Measure extraction on a large accessibility tree and DOM snapshot fixture.
+- Measure matching with many same-role candidates.
+- Track bundle size from `dist/index.js` and `dist/index.cjs`.
+- Consider caching normalized names/tokens only if benchmark evidence shows it matters.
+- Avoid adding browser runtime dependencies for performance tests unless separately approved.
+
+**Exit criteria:**
+
+- Release has explicit performance evidence.
+- Hot-path regressions are visible before publishing.
+
+**Initial budgets:**
+
+- Large extraction: 600 accessible buttons with matching DOM/layout evidence must extract within 2,000 ms.
+- Same-role scoring: 2,500 button candidates must score and sort within 1,500 ms.
+- Bundle size after `npm run build`: `dist/index.js` must stay <= 75,000 raw bytes, `dist/index.cjs` must stay <= 85,000 raw bytes, and combined gzip size must stay <= 45,000 bytes.
+- Current build sizes: `dist/index.js` 31,678 raw bytes / 8,281 gzip bytes; `dist/index.cjs` 35,101 raw bytes / 8,990 gzip bytes; combined gzip 17,271 bytes.
+
+**Verification:**
+
+- RED budget: `npm test -- src/__tests__/performance-budget.test.ts` failed before the extractor optimization because 600-element extraction took 2,259 ms against a 2,000 ms budget.
+- GREEN budget: `npm test -- src/__tests__/performance-budget.test.ts` passed, 1 test file and 3 tests.
+- `npm run build`: passed.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 9 test files and 152 tests.
+- `npm audit --json`: failed with the pre-existing 2 vulnerabilities: high transitive `vite` and moderate transitive `postcss`. This remains assigned to Task 7.
+
+### Task 7: Resolve npm Audit And Package Metadata Drift
+
+**DoD:** Security, Release & distribution, Repo hygiene
+
+**Status:** Complete for Phase 3 Task 7 on 2026-05-04. Captured the starting audit failure and package metadata drift, then fixed both with lockfile-only dependency movement. `package.json` remains at the local release baseline `@pyyush/useid@0.2.0`; `package-lock.json` now has top-level and root package metadata aligned to `@pyyush/useid@0.2.0`.
+
+**Files likely touched in Phase 3:**
+
+- `package.json`
+- `package-lock.json`
+
+**Plan:**
+
+- Update transitive dev tooling through npm after orchestrator approval for toolchain changes.
+- Confirm `npm audit --json` reports zero known vulnerabilities.
+- Align `package-lock.json` root name with `@pyyush/useid`.
+- Verify `npm pack --dry-run` includes intended public files only.
+
+**Exit criteria:**
+
+- Audit clean or explicitly risk-accepted by a human.
+- Package metadata matches npm scope and release target.
+
+**Captured baseline:**
+
+- Starting `npm audit --json`: 2 vulnerabilities, high transitive `vite` and moderate transitive `postcss`.
+- Starting metadata: `package.json` was `@pyyush/useid@0.2.0`; `package-lock.json` top-level/root package metadata was `useid@0.2.0`.
+
+**Fix:**
+
+- Ran `npm update vite postcss --package-lock-only`.
+- Lockfile now resolves `vite@8.0.10` and `postcss@8.5.13`.
+- Lockfile top-level and root package metadata now match `@pyyush/useid@0.2.0`.
+- No `package.json` edits were required for this task.
+
+**Verification:**
+
+- `npm run build`: passed.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 9 test files and 152 tests.
+- `npm audit --json`: passed with 0 vulnerabilities.
+- `npm pack --dry-run`: passed for `@pyyush/useid@0.2.0`, package size 34.0 kB, unpacked size 147.1 kB, total files 8.
+
+### Task 8: Update Public Docs, Examples, And Browser-Harness-Facing Guides
+
+**DoD:** Docs, Value, Observability, Browser-harness research
+
+**Status:** Complete for Phase 3 Task 8 on 2026-05-04. README, changelog, and the grounding-gate example now cover the current release status, 0.1.0 -> 1.0.0 migration notes, privacy/security handling, observability/debug guidance, and browser-harness learn-from usage without adding any browser-harness dependency, backend, runtime bridge, or required runtime.
+
+**Files likely touched in Phase 3:**
+
+- `README.md`
+- `CHANGELOG.md`
+- `examples/grounding-gate.ts`
+- possible new docs files if public docs scope is approved
+
+**Plan:**
+
+- Align README version/support text with the actual release target.
+- Add migration notes from published `0.1.0` to `1.0.0`.
+- Add security/privacy guidance for snapshot-derived text, redaction, and candidate diagnostics.
+- Add observability guidance for logging confidence, abstention reasons, score gaps, and redacted explanations.
+- Add browser-harness-facing grounding example that resolves a target only after uSEID confidence/abstention gating.
+- Document the snapshot boundary needed to drive uSEID from browser-harness CDP access, including strong-fit and abstain cases.
+- Add an interop guide mapping browser-harness actions to uSEID-safe resolution steps so teams can adopt safety gating without a custom contract.
+
+**Exit criteria:**
+
+- A browser-agent builder can understand when to act, when to abstain, and what evidence is required.
+- Browser-harness content is framed as design/docs/examples alignment, not a runtime integration.
+
+**Verification:**
+
+- `npm run build`: passed.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 9 test files and 152 tests.
+- `npm audit --json`: passed with 0 vulnerabilities.
+- `npm pack --dry-run`: passed for `@pyyush/useid@0.2.0`, 8 files.
+- `npx tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --strict --skipLibCheck examples/grounding-gate.ts`: passed.
+
+### Task 9: Tighten CI And Release Hygiene Without Locking Browser Matrix
+
+**DoD:** Repo hygiene, Release & distribution, Tests
+
+**Status:** Complete for Phase 3 Task 9 on 2026-05-04. Release verification now runs on the existing Node 20/22 matrix with build, typecheck, full Vitest, explicit performance-budget tests, clean `npm audit --json`, package-content inspection, and provenance publish gating. No browser CI matrix, browser-harness dependency, browser runtime dependency, browser version lock, Playwright/Puppeteer dependency, or build-tool matrix was added.
+
+**Files likely touched in Phase 3:**
+
+- `.github/workflows/ci.yml`
+- `.github/workflows/release.yml`
+- `.gitignore`
+- package metadata only if implementation scope permits
+
+**Plan:**
+
+- Keep current baseline: npm lockfile workflow, tsup build, TypeScript typecheck, Vitest tests, Node 20/22 CI matrix.
+- Improve release hygiene checks for internal-only files if needed.
+- Do not add a browser CI matrix until orchestrator reconciles useid with dbar's audit/plan.
+- If adding lint/format is approved, keep it minimal and consistent with existing TypeScript tooling.
+
+**Checklist:**
+
+- [x] Preserved npm lockfile workflow with `npm ci`.
+- [x] Preserved tsup build, TypeScript typecheck, Vitest tests, and Node 20/22 CI/release verification baseline.
+- [x] Added release hygiene checks for internal-only repository and package contents.
+- [x] Added explicit release gate coverage for performance-budget tests and clean `npm audit --json`.
+- [x] Kept local `release:verify` aligned with build, typecheck, tests, performance budget, audit, and pack dry-run gates.
+- [x] Kept browser matrix and browser-harness/runtime dependency work out of scope.
+
+**Exit criteria:**
+
+- CI/release gates are reliable for the current Node baseline.
+- Browser matrix remains proposed, not locked.
+
+**Verification:**
+
+- `npm run build`: passed; tsup produced ESM, CJS, and DTS artifacts.
+- `npm run typecheck`: passed.
+- `npm test`: passed, 9 test files and 152 tests.
+- `npm audit --json`: passed with 0 vulnerabilities.
+- `npm pack --dry-run`: passed for `@pyyush/useid@0.2.0`, 8 files, package size 37.0 kB.
+- Workflow YAML parse: passed for `.github/workflows/ci.yml` and `.github/workflows/release.yml`.
+
+### Task 10: Run RC Gate And External Validation
+
+**DoD:** Release & distribution, Value, Stability, Docs
+
+**Status:** In progress for Phase 3 Task 10 on 2026-05-04. Local RC gate preparation is complete, but Task 10 is not complete because no actual RC artifact/tag/publish has been created and no external or external-like user validation has occurred.
+
+**Current RC gate status:**
+
+- Local release verification gate: latest full `npm run release:verify` passed on 2026-05-04.
+- Timing note: the first `npm run release:verify` attempt failed in the focused performance-budget step because 600-element extraction took about 2531 ms against the 2000 ms budget. A focused rerun of `npm test -- src/__tests__/performance-budget.test.ts` passed, and a second full `npm run release:verify` passed. Treat the extraction budget as timing-sensitive evidence to watch in CI/RC.
+- Public npm latest: registry reports `@pyyush/useid@0.1.0` under the `latest` dist-tag.
+- Local package: current branch package metadata is `@pyyush/useid@0.2.0`.
+- Stable release target in this plan: `1.0.0`; it is not published and no RC has been cut.
+- Package dry-run contents: 8 files only: `CHANGELOG.md`, `LICENSE`, `README.md`, `dist/index.cjs`, `dist/index.d.cts`, `dist/index.d.ts`, `dist/index.js`, and `package.json`.
+- Internal package-content check: no `AGENTS.md`, `CLAUDE.md`, `AUDIT.md`, `RELEASE_PLAN.md`, `RC_VALIDATION.md`, `docs/plans`, `.omx`, `.bap`, `.banners`, `coverage`, `examples`, or `src` files were included in the dry-run package.
+
+**Plan:**
+
+- Cut an RC only after local and CI gates pass.
+- Run package install/import checks from a clean external sample project.
+- Ask at least one external or external-like browser-agent user to validate the grounding-gate docs/example.
+- Record feedback and decide whether it blocks `1.0.0`.
+
+**Concrete blockers before Task 10 can complete:**
+
+- Clean release branch/worktree is required; the current workspace still contains broad pre-existing dirty source, test, docs, package, workflow, and release-plan work.
+- Registry credentials and npm provenance context are required for an actual RC publish path.
+- An actual RC artifact/tag/package is required; no tag or publish was created during this local preparation.
+- At least one external or external-like RC user is required; none has validated the package/docs/example yet.
+
+**Required external-user evidence fields:**
+
+- Validator name or role:
+- Date:
+- RC artifact used, version, and install source:
+- Clean sample project or host environment:
+- Import/API smoke result:
+- Grounding-gate docs/example reviewed:
+- Strong-fit scenario result:
+- Abstention/ambiguous scenario result:
+- Package contents or security concerns:
+- Feedback summary:
+- Release-blocking disposition:
+- Follow-up owner:
+
+**Local verification:**
+
+- `npm run release:verify`: first attempt failed on focused performance extraction budget; second full attempt passed.
+- `npm test -- src/__tests__/performance-budget.test.ts`: passed on focused rerun, 1 test file and 3 tests.
+- `npm audit --json`: passed with 0 vulnerabilities.
+- `npm pack --dry-run --json`: passed for `@pyyush/useid@0.2.0`, package size 36,995 bytes, unpacked size 156,548 bytes, 8 files.
+- Package internal-file assertion from dry-run JSON: passed with an empty forbidden-file list.
+- `npm view @pyyush/useid version --json`: `0.1.0`.
+- `npm view @pyyush/useid dist-tags --json`: `{ "latest": "0.1.0" }`.
+- Local package metadata inspection: `@pyyush/useid@0.2.0`, `publishConfig.access` is `public`, registry is `https://registry.npmjs.org/`.
+
+**Exit criteria:**
+
+- RC evidence exists.
+- External validation is complete or explicitly waived by a human.
+
+### Task 11: Final Release Gate And Publish
+
+**DoD:** Release & distribution, Security, Repo hygiene
+
+**Status:** Blocked until Task 10 passes.
+
+**Plan:**
+
+- Verify `npm ci`, `npm run build`, `npm run typecheck`, `npm test`, coverage, `npm audit`, and `npm pack --dry-run`.
+- Confirm package version and tag are `1.0.0`.
+- Publish with npm provenance through the release workflow.
+- Create a GitHub release with clear release notes and known limits.
+
+**Exit criteria:**
+
+- npm latest is `@pyyush/useid@1.0.0`.
+- GitHub release exists.
+- Known support limits and browser-harness non-integration scope are documented.
+
+## Dependencies
+
+- Task 1 blocks every implementation task because the worktree is already dirty.
+- Task 3 should land before Tasks 4, 5, and 8 because stable schemas and result contracts affect tests and docs.
+- Task 2 should land before docs and release validation because privacy behavior must not be documented around a known bug.
+- Task 5 depends on Tasks 3 and 4 so fixtures assert final API and safety behavior.
+- Task 8 depends on Tasks 2, 3, and 5 so docs match fixed behavior and fixture-backed support claims.
+- Task 10 depends on Tasks 2-9.
+- Task 11 depends on Task 10.
+
+## Risks And Blockers
+
+Current blockers for release:
+
+- Public npm latest is `0.1.0`, while local package metadata is `0.2.0` and the stable target remains `1.0.0`; no RC artifact/tag/package has been created.
+- A clean release branch/worktree is still required; the workspace remains dirty with pre-existing source, test, package, workflow, README, changelog, examples, fixture, and fingerprint changes.
+- Registry credentials and npm provenance context have not been verified for an actual RC publish path.
+- At least one external or external-like RC user still needs to validate the package/docs/example.
+- Performance-budget timing was sensitive during local RC prep: one focused extraction-budget run failed before subsequent focused and full-gate reruns passed.
+
+Risks to manage:
+
+- API tightening could break current local examples or downstream pre-1.0 users.
+- Browser support matrix work could expand into browser orchestration; keep it as evidence/docs unless orchestrator approves more.
+- Adding browser-harness as a dependency would materially change scope and cross-language maintenance burden.
+- Toolchain updates for audit fixes may alter Vitest/Vite behavior; route material Node/browser/build-tool matrix changes back to the orchestrator.
+- Redacted diagnostics can reduce debug value; preserve operator usefulness without leaking snapshot-derived text.
+
+## Browser-Harness Research Outcome
+
+Orchestrator-approved decision for useid: `learn-from`.
+
+Approved next-release scope:
+
+- Docs/examples/design alignment only.
+- No browser-harness package dependency.
+- No backend or required runtime.
+- No formal runtime bridge without human confirmation.
+
+Approved actions to plan:
+
+1. Add a browser-harness-facing grounding example that resolves a target only after uSEID confidence/abstention gating.
+2. Document the snapshot boundary needed to drive uSEID from browser-harness CDP access, including strong-fit and abstain cases.
+3. Add an interop guide mapping browser-harness actions to uSEID-safe resolution steps so teams can adopt safety gating without a custom contract.
+
+Scope flag:
+
+- A formal runtime bridge or hard browser-harness integration would materially change scope and timeline and requires human confirmation before acting.
+
+## Browser And Toolchain Matrix
+
+Current baseline:
+
+- Package manager: npm lockfile workflow.
+- Build: tsup.
+- Typecheck: TypeScript `tsc --noEmit`.
+- Tests: Vitest in Node environment.
+- Runtime engine: Node `>=20.0.0`.
+- CI matrix: Node `20` and `22`.
+- No explicit browser execution matrix exists in CI today.
+
+Candidate browser CI options for orchestrator/dbar reconciliation:
+
+- Minimal evidence option: Node 20/22 plus Chromium stable snapshot smoke fixtures.
+- Support-matrix option: Chromium stable fixtures for main frame, same-origin iframe, open shadow DOM, and closed shadow DOM abstention documentation.
+- Shared-browser-constraints option: adopt a dbar-aligned browser version policy only after the orchestrator reconciles both release plans.
+- Future compatibility option: evaluate Node 24 only after release-critical audit and dbar/browser constraints are resolved.
+
+Decision:
+
+- Keep Node 20/22 as the current release-planning baseline.
+- Do not lock any browser CI matrix, browser version, Playwright/Puppeteer dependency, browser-harness dependency, or build-tool matrix in this plan.
+
+## Release Gates
+
+### Implementation Gate
+
+Status: Local preparation evidence exists from Tasks 2-10, with the latest `npm run release:verify` passing. A clean release branch and CI confirmation are still required before RC.
+
+Required evidence:
+
+- `npm ci`
+- `npm run build`
+- `npm run typecheck`
+- `npm test`
+- coverage report
+- `npm audit`
+- `npm pack --dry-run`
+- targeted fixture/performance checks added by this plan
+
+### RC Gate
+
+Status: Blocked until a clean release branch exists and an actual RC artifact/tag/package is created.
+
+Required evidence:
+
+- `1.0.0-rc` package or dry-run artifact reviewed from a clean checkout.
+- API and package contents reviewed.
+- README, changelog, migration notes, support limits, and browser-harness docs/examples reviewed.
+- Known bugs and accepted limitations documented.
+
+### External-User Gate
+
+Status: Blocked until RC gate passes.
+
+Required evidence:
+
+- At least one external or external-like browser-agent builder validates the grounding-gate example and docs.
+- Feedback is recorded with explicit release-blocking/non-blocking disposition.
+- Any waived external validation requires human confirmation.
+
+### Final Release Gate
+
+Status: Blocked until external-user gate passes or is waived by a human.
+
+Required evidence:
+
+- Clean release branch/worktree except intended release artifacts.
+- CI green on Node 20/22.
+- Security audit clean or human risk acceptance recorded.
+- npm publish provenance path verified.
+- GitHub release notes include support matrix, known limits, and browser-harness scope boundary.
+
+## Definition Of Done Delta
+
+Remaining release delta after Task 10 local preparation:
+
+- RC artifact evidence from a clean release branch.
+- External or external-like user validation evidence.
+- Final publish/release evidence after credentials, provenance, and release notes are verified.
+
+## Phase 3 Starting Point
+
+Phase 3 task 1 should be: reconcile release baseline and dirty worktree ownership, then fix the redaction privacy bug with tests if the orchestrator confirms file ownership.
diff --git a/examples/grounding-gate.ts b/examples/grounding-gate.ts
new file mode 100644
index 0000000..eec3c00
--- /dev/null
+++ b/examples/grounding-gate.ts
@@ -0,0 +1,159 @@
+/**
+ * Repo example only: copy/adapt this grounding-gate pattern in your own host
+ * application rather than importing this file from the published package.
+ */
+
+import type {
+ AccessibilitySnapshotResult,
+ CandidateScores,
+ DOMSnapshotResult,
+ FramePathEntry,
+ MatchWeights,
+ USEIDAbstentionReason,
+ ResolveResult,
+ USEIDSignature,
+ USEIDConfig,
+} from "../src/index.js";
+import { resolveUSEID } from "../src/index.js";
+
+type BrowserHarness = {
+ captureDOMSnapshot(): Promise;
+ captureAccessibilitySnapshot(): Promise;
+ currentUrl(): Promise;
+ currentFramePath?(): Promise;
+ recordGrounding?(log: GroundingLog): Promise | void;
+ click(selectorHint: string): Promise;
+};
+
+type SnapshotBundle = {
+ domSnapshot: DOMSnapshotResult;
+ accessibilitySnapshot: AccessibilitySnapshotResult;
+ pageUrl: string;
+ framePath?: FramePathEntry[];
+};
+
+export type GroundingLog = {
+ resolved: boolean;
+ abstentionReason?: USEIDAbstentionReason;
+ confidence?: number;
+ scoreGap?: number;
+ scores?: CandidateScores;
+ threshold: number;
+ marginConstraint: number;
+ weights?: MatchWeights;
+ candidateCount?: number;
+ topCandidateScoreBands?: Array<{
+ confidence: number;
+ scores: CandidateScores;
+ }>;
+ explanation: string;
+};
+
+export type GroundingGateOptions = {
+ confidenceThreshold?: number;
+ marginConstraint?: number;
+ weights?: MatchWeights;
+};
+
+const DEFAULT_CONFIDENCE_THRESHOLD = 0.85;
+const DEFAULT_MARGIN_CONSTRAINT = 0.1;
+
+export async function clickOnlyWhenGrounded(
+ harness: BrowserHarness,
+ signature: USEIDSignature,
+ options: GroundingGateOptions = {}
+): Promise {
+ const current = await captureSnapshotBoundary(harness);
+ const result = resolveWithGate(signature, current, options);
+ const groundingLog = toGroundingLog(result, options);
+
+ await harness.recordGrounding?.(groundingLog);
+
+ if (result.resolved === false) {
+ throw new Error(`uSEID abstained: ${result.abstentionReason} - ${groundingLog.explanation}`);
+ }
+
+ await harness.click(result.selectorHint);
+}
+
+export function resolveWithGate(
+ signature: USEIDSignature,
+ current: SnapshotBundle,
+ options: GroundingGateOptions = {}
+): ResolveResult {
+ return resolveUSEID({
+ signature,
+ domSnapshot: current.domSnapshot,
+ accessibilitySnapshot: current.accessibilitySnapshot,
+ pageUrl: current.pageUrl,
+ framePath: current.framePath,
+ config: toResolveConfig(options),
+ });
+}
+
+export async function captureSnapshotBoundary(harness: BrowserHarness): Promise {
+ const [domSnapshot, accessibilitySnapshot, pageUrl, framePath] = await Promise.all([
+ harness.captureDOMSnapshot(),
+ harness.captureAccessibilitySnapshot(),
+ harness.currentUrl(),
+ harness.currentFramePath?.() ?? Promise.resolve(undefined),
+ ]);
+
+ return { domSnapshot, accessibilitySnapshot, pageUrl, framePath };
+}
+
+export function toGroundingLog(
+ result: ResolveResult,
+ options: GroundingGateOptions = {}
+): GroundingLog {
+ const threshold = options.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD;
+ const marginConstraint = options.marginConstraint ?? DEFAULT_MARGIN_CONSTRAINT;
+
+ if (result.resolved) {
+ return {
+ resolved: true,
+ confidence: result.confidence,
+ scoreGap: result.scoreGap,
+ scores: result.scores,
+ threshold,
+ marginConstraint,
+ weights: options.weights,
+ explanation: "resolved",
+ };
+ }
+
+ return {
+ resolved: false,
+ abstentionReason: result.abstentionReason,
+ threshold,
+ marginConstraint,
+ weights: options.weights,
+ candidateCount: result.candidates.length,
+ topCandidateScoreBands: result.candidates.slice(0, 3).map((candidate) => ({
+ confidence: candidate.confidence,
+ scores: candidate.scores,
+ })),
+ explanation: redactedAbstentionExplanation(result.abstentionReason),
+ };
+}
+
+function toResolveConfig(options: GroundingGateOptions): Partial {
+ return {
+ threshold: options.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD,
+ marginConstraint: options.marginConstraint ?? DEFAULT_MARGIN_CONSTRAINT,
+ weights: options.weights,
+ };
+}
+
+function redactedAbstentionExplanation(reason: USEIDAbstentionReason): string {
+ switch (reason) {
+ case "binding_mismatch":
+ return "signature binding did not match the current page or frame";
+ case "no_candidates":
+ return "no same-role candidates were present in the current snapshot";
+ case "below_threshold":
+ return "best candidate did not meet the configured confidence threshold";
+ case "ambiguous_match":
+ return "top candidates were too close to act safely";
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index a0e778b..2778ce5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "useid",
- "version": "0.1.0",
+ "name": "@pyyush/useid",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "useid",
- "version": "0.1.0",
+ "name": "@pyyush/useid",
+ "version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.23.0"
@@ -22,21 +22,21 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
- "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.2.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
- "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -45,9 +45,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
- "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -537,26 +537,28 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
- "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
- "version": "0.122.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
- "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
+ "version": "0.127.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+ "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -564,9 +566,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
- "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [
"arm64"
],
@@ -581,9 +583,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
- "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [
"arm64"
],
@@ -598,9 +600,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
- "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [
"x64"
],
@@ -615,9 +617,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
- "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [
"x64"
],
@@ -632,9 +634,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
- "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
+ "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [
"arm"
],
@@ -649,9 +651,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
- "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [
"arm64"
],
@@ -669,9 +671,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
- "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [
"arm64"
],
@@ -689,9 +691,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
- "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [
"ppc64"
],
@@ -709,9 +711,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
- "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [
"s390x"
],
@@ -729,9 +731,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
- "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [
"x64"
],
@@ -749,9 +751,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
- "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [
"x64"
],
@@ -769,9 +771,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
- "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [
"arm64"
],
@@ -786,9 +788,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
- "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
+ "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [
"wasm32"
],
@@ -796,16 +798,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@napi-rs/wasm-runtime": "^1.1.1"
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
- "node": ">=14.0.0"
+ "node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
- "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [
"arm64"
],
@@ -820,9 +824,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
- "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [
"x64"
],
@@ -837,9 +841,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
- "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
+ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"dev": true,
"license": "MIT"
},
@@ -1240,9 +1244,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
- "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2101,9 +2105,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.8",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{
@@ -2197,14 +2201,14 @@
}
},
"node_modules/rolldown": {
- "version": "1.0.0-rc.11",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
- "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
+ "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.122.0",
- "@rolldown/pluginutils": "1.0.0-rc.11"
+ "@oxc-project/types": "=0.127.0",
+ "@rolldown/pluginutils": "1.0.0-rc.17"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -2213,21 +2217,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.11",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.11",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.11",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.11",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11"
+ "@rolldown/binding-android-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/rollup": {
@@ -2377,14 +2381,14 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -2510,17 +2514,17 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
- "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
+ "version": "8.0.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
+ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.8",
- "rolldown": "1.0.0-rc.11",
- "tinyglobby": "^0.2.15"
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.10",
+ "rolldown": "1.0.0-rc.17",
+ "tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -2537,7 +2541,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
- "esbuild": "^0.27.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
diff --git a/package.json b/package.json
index 4f843be..608cb8a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@pyyush/useid",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "uSEID - Universal Semantic Element ID for stable cross-run element identity",
"author": "Piyush Vyas",
"license": "Apache-2.0",
@@ -23,6 +23,8 @@
},
"files": [
"dist",
+ "README.md",
+ "CHANGELOG.md",
"LICENSE"
],
"repository": {
@@ -34,6 +36,8 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
+ "release:verify": "npm run build && npm run typecheck && npm test && npm test -- src/__tests__/performance-budget.test.ts && npm audit --json && npm pack --dry-run >/dev/null",
+ "prepublishOnly": "npm run build",
"clean": "rm -rf dist"
},
"dependencies": {
diff --git a/src/__tests__/builder.test.ts b/src/__tests__/builder.test.ts
index 8e0b064..218f62e 100644
--- a/src/__tests__/builder.test.ts
+++ b/src/__tests__/builder.test.ts
@@ -141,6 +141,41 @@ describe("buildUSEID", () => {
expect(sig0.semantic.accessibleName).not.toBe(sig1.semantic.accessibleName);
});
+ it("should include structural context in the hash to avoid duplicate-name collisions", () => {
+ const duplicateNameTree = {
+ role: "WebArea",
+ name: "Page",
+ children: [
+ {
+ role: "group",
+ name: "",
+ children: [{ role: "button", name: "Open" }],
+ },
+ {
+ role: "article",
+ name: "",
+ children: [{ role: "button", name: "Open" }],
+ },
+ ],
+ };
+
+ const navSig = buildUSEID({
+ domSnapshot: makeDOMSnapshot(null),
+ accessibilitySnapshot: makeAXSnapshot(duplicateNameTree),
+ elementIndex: 0,
+ pageUrl,
+ });
+ const mainSig = buildUSEID({
+ domSnapshot: makeDOMSnapshot(null),
+ accessibilitySnapshot: makeAXSnapshot(duplicateNameTree),
+ elementIndex: 1,
+ pageUrl,
+ });
+
+ expect(navSig.semantic.accessibleName).toBe(mainSig.semantic.accessibleName);
+ expect(navSig.hash).not.toBe(mainSig.hash);
+ });
+
it("should set sibling tokens from adjacent elements", () => {
const sig = buildUSEID({
domSnapshot: makeDOMSnapshot(null),
diff --git a/src/__tests__/extractor.test.ts b/src/__tests__/extractor.test.ts
index a08d372..8b26128 100644
--- a/src/__tests__/extractor.test.ts
+++ b/src/__tests__/extractor.test.ts
@@ -157,6 +157,161 @@ describe("extractElements", () => {
expect(button!.bbox).toBeDefined();
});
+ it("matches repeated tags using DOM text content instead of depth alone", () => {
+ const domSnapshot = makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 1, 4],
+ nodeType: [1, 1, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 3, 2, 3],
+ nodeValue: [3, 3, 3, 4, 3, 5],
+ backendNodeId: [1, 2, 3, 4, 5, 6],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 4],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 400, 200],
+ [10, 10, 120, 40],
+ [10, 60, 120, 40],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "div", "button", "", "Save", "Cancel"],
+ });
+
+ const axTree = {
+ role: "WebArea",
+ name: "Page",
+ children: [
+ { role: "button", name: "Cancel" },
+ { role: "button", name: "Save" },
+ ],
+ };
+
+ const elements = extractElements(domSnapshot, makeAXSnapshot(axTree));
+ const cancel = elements.find((e) => e.accessibleName === "cancel");
+ const save = elements.find((e) => e.accessibleName === "save");
+
+ expect(cancel?.bbox).toEqual({ x: 10, y: 60, w: 120, h: 40 });
+ expect(save?.bbox).toEqual({ x: 10, y: 10, w: 120, h: 40 });
+ });
+
+ it("matches DOM nodes by accessible-name text when multiple same-tag candidates exist", () => {
+ const domSnapshot = makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 1, 4],
+ nodeType: [1, 1, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 4, 2, 5],
+ nodeValue: [3, 3, 3, 4, 3, 5],
+ backendNodeId: [1, 2, 3, 4, 5, 6],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 4],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [10, 10, 120, 40],
+ [10, 60, 120, 40],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "div", "button", "", "Save", "Delete"],
+ });
+
+ const axTree = {
+ role: "WebArea",
+ name: "Page",
+ children: [
+ { role: "button", name: "Save" },
+ { role: "button", name: "Delete" },
+ ],
+ };
+
+ const elements = extractElements(domSnapshot, makeAXSnapshot(axTree));
+ const deleteButton = elements.find((e) => e.accessibleName === "delete");
+ expect(deleteButton).toBeDefined();
+ expect(deleteButton!.bbox).toEqual({ x: 10, y: 60, w: 120, h: 40 });
+ });
+
+ it("does not assign DOM geometry when same-tag DOM text conflicts with the a11y name", () => {
+ const domSnapshot = makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 1, 4],
+ nodeType: [1, 1, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 3, 2, 3],
+ nodeValue: [3, 3, 3, 4, 3, 5],
+ backendNodeId: [1, 2, 3, 4, 5, 6],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 4],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [10, 10, 120, 40],
+ [10, 60, 120, 40],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "div", "button", "", "Save", "Delete"],
+ });
+
+ const axTree = {
+ role: "WebArea",
+ name: "Page",
+ children: [{ role: "button", name: "Archive" }],
+ };
+
+ const elements = extractElements(domSnapshot, makeAXSnapshot(axTree));
+ const archive = elements.find((e) => e.accessibleName === "archive");
+ expect(archive).toBeDefined();
+ expect(archive!.bbox).toEqual({ x: 0, y: 0, w: 0, h: 0 });
+ expect(archive!.ancestorTags).toEqual([]);
+ });
+
+ it("uses zero bbox when a DOM snapshot has no layout entry for the matched element", () => {
+ const domSnapshot = makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2],
+ nodeType: [1, 1, 1, 3],
+ nodeName: [0, 1, 2, 3],
+ nodeValue: [3, 3, 3, 4],
+ backendNodeId: [1, 2, 3, 4],
+ },
+ layout: {
+ nodeIndex: [0, 1],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "div", "button", "", "Save"],
+ });
+
+ const axTree = {
+ role: "WebArea",
+ name: "Page",
+ children: [{ role: "button", name: "Save" }],
+ };
+
+ const elements = extractElements(domSnapshot, makeAXSnapshot(axTree));
+ const save = elements.find((e) => e.accessibleName === "save");
+ expect(save).toBeDefined();
+ expect(save!.bbox).toEqual({ x: 0, y: 0, w: 0, h: 0 });
+ });
+
it("filters out generic roles without names", () => {
const axTree = {
role: "WebArea",
diff --git a/src/__tests__/fixtures/realistic-snapshots.ts b/src/__tests__/fixtures/realistic-snapshots.ts
new file mode 100644
index 0000000..797da4e
--- /dev/null
+++ b/src/__tests__/fixtures/realistic-snapshots.ts
@@ -0,0 +1,319 @@
+import type { AccessibilitySnapshotResult, DOMSnapshotResult } from "../../snapshot-types.js";
+import type { FramePathEntry } from "../../types.js";
+
+function makeAXSnapshot(tree: unknown): AccessibilitySnapshotResult {
+ return { tree, hash: "fixture-ax", serialized: "{}" };
+}
+
+function makeDOMSnapshot(snapshot: unknown): DOMSnapshotResult {
+ return { snapshot, hash: "fixture-dom", serialized: "{}" };
+}
+
+export const realisticPageUrl = "https://example.com/page";
+export const sameOriginFramePath: FramePathEntry[] = [
+ { url: "https://example.com/account-frame", index: 0 },
+];
+
+export const wrapperLayoutChurnFixture = {
+ before: {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Checkout",
+ children: [
+ {
+ role: "group",
+ name: "",
+ children: [
+ { role: "button", name: "Continue" },
+ { role: "button", name: "Back" },
+ ],
+ },
+ ],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 3, 4, 3, 6],
+ nodeType: [1, 1, 1, 1, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 3, 4, 5, 4, 5],
+ nodeValue: [5, 5, 5, 5, 5, 6, 5, 7],
+ backendNodeId: [1, 2, 3, 4, 5, 6, 7, 8],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 3, 4, 6],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [80, 240, 420, 220],
+ [90, 260, 380, 180],
+ [120, 310, 140, 44],
+ [280, 310, 100, 44],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "div", "form", "button", "", "Continue", "Back"],
+ }),
+ },
+ after: {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Checkout",
+ children: [
+ {
+ role: "group",
+ name: "",
+ children: [
+ {
+ role: "group",
+ name: "",
+ children: [
+ { role: "button", name: "Continue" },
+ { role: "button", name: "Back" },
+ ],
+ },
+ ],
+ },
+ ],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 3, 4, 5, 4, 7],
+ nodeType: [1, 1, 1, 1, 1, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 8, 3, 4, 5, 4, 5],
+ nodeValue: [5, 5, 5, 5, 5, 5, 6, 5, 7],
+ backendNodeId: [11, 12, 13, 14, 15, 16, 17, 18, 19],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 3, 4, 5, 7],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [110, 265, 450, 240],
+ [120, 280, 430, 220],
+ [130, 295, 390, 190],
+ [160, 350, 140, 44],
+ [320, 350, 100, 44],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "div", "form", "button", "", "Continue", "Back", "section"],
+ }),
+ },
+};
+
+export const duplicateSameNameFixture = {
+ before: {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Review",
+ children: [
+ {
+ role: "group",
+ name: "",
+ children: [
+ { role: "button", name: "Approve" },
+ { role: "button", name: "Cancel" },
+ ],
+ },
+ ],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 3, 4, 3, 6],
+ nodeType: [1, 1, 1, 1, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 3, 4, 5, 4, 5],
+ nodeValue: [5, 5, 5, 5, 5, 6, 5, 7],
+ backendNodeId: [21, 22, 23, 24, 25, 26, 27, 28],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 3, 4, 6],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [40, 80, 360, 180],
+ [50, 90, 330, 150],
+ [100, 140, 120, 40],
+ [240, 140, 100, 40],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "div", "form", "button", "", "Approve", "Cancel"],
+ }),
+ },
+ after: {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Review",
+ children: [
+ {
+ role: "group",
+ name: "",
+ children: [
+ { role: "button", name: "Approve" },
+ { role: "button", name: "Approve" },
+ { role: "button", name: "Cancel" },
+ ],
+ },
+ ],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 3, 4, 3, 6, 3, 8],
+ nodeType: [1, 1, 1, 1, 1, 3, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 3, 4, 5, 4, 5, 4, 5],
+ nodeValue: [5, 5, 5, 5, 5, 6, 5, 6, 5, 7],
+ backendNodeId: [31, 32, 33, 34, 35, 36, 37, 38, 39, 40],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 3, 4, 6, 8],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [40, 80, 420, 180],
+ [50, 90, 390, 150],
+ [100, 140, 120, 40],
+ [112, 142, 120, 40],
+ [260, 140, 100, 40],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "div", "form", "button", "", "Approve", "Cancel"],
+ }),
+ },
+};
+
+export const missingRoleAndNameFixture = {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Checkout",
+ children: [{ role: "generic", name: "" }],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1],
+ nodeType: [1, 1, 1],
+ nodeName: [0, 1, 2],
+ nodeValue: [3, 3, 3],
+ backendNodeId: [41, 42, 43],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [100, 100, 240, 80],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "div", ""],
+ }),
+};
+
+export const sameOriginIframeFixture = {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Account Frame",
+ children: [
+ { role: "button", name: "Save Settings" },
+ { role: "button", name: "Cancel" },
+ ],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 1, 4],
+ nodeType: [1, 1, 1, 3, 1, 3],
+ nodeName: [0, 1, 2, 3, 2, 3],
+ nodeValue: [3, 3, 3, 4, 3, 5],
+ backendNodeId: [51, 52, 53, 54, 55, 56],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 4],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 480, 320],
+ [40, 80, 160, 44],
+ [220, 80, 120, 44],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "button", "", "Save Settings", "Cancel"],
+ }),
+};
+
+export const openShadowFixture = {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Shadow Demo",
+ children: [{ role: "button", name: "Shadow Action" }],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1, 2, 3, 4],
+ nodeType: [1, 1, 1, 11, 1, 3],
+ nodeName: [0, 1, 2, 3, 4, 5],
+ nodeValue: [5, 5, 5, 5, 5, 6],
+ backendNodeId: [61, 62, 63, 64, 65, 66],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2, 4],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [300, 200, 220, 120],
+ [320, 235, 160, 44],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "my-widget", "#document-fragment", "button", "", "Shadow Action"],
+ }),
+};
+
+export const closedShadowFixture = {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Shadow Demo",
+ children: [{ role: "button", name: "Shadow Action" }],
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: {
+ parentIndex: [-1, 0, 1],
+ nodeType: [1, 1, 1],
+ nodeName: [0, 1, 2],
+ nodeValue: [3, 3, 3],
+ backendNodeId: [71, 72, 73],
+ },
+ layout: {
+ nodeIndex: [0, 1, 2],
+ bounds: [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ [300, 200, 220, 120],
+ ],
+ },
+ },
+ ],
+ strings: ["html", "body", "my-widget", ""],
+ }),
+};
diff --git a/src/__tests__/matcher.test.ts b/src/__tests__/matcher.test.ts
index f2d08b5..8de93fb 100644
--- a/src/__tests__/matcher.test.ts
+++ b/src/__tests__/matcher.test.ts
@@ -72,6 +72,15 @@ describe("scoreCandidates", () => {
expect(results[0]!.scores.semantic).toBe(1.0);
});
+ it("should return semantic score of zero when accessible names are missing", () => {
+ const sig = makeSignature({ semantic: { role: "button", accessibleName: "" } });
+ const candidate = makeCandidate({ accessibleName: "" });
+
+ const results = scoreCandidates(sig, [candidate]);
+ expect(results[0]!.scores.semantic).toBe(0);
+ expect(results[0]!.confidence).toBeLessThan(0.5);
+ });
+
it("should return semantic score of 0.8 for normalized name match", () => {
const sig = makeSignature({ semantic: { role: "button", accessibleName: "Submit Form" } });
const candidate = makeCandidate({ accessibleName: "submit form" });
@@ -108,8 +117,8 @@ describe("scoreCandidates", () => {
});
const results = scoreCandidates(sig, [candidate]);
- // ancestorSim=1.0, siblingSim=1.0, depthSim=1.0
- // structural = 1.0*0.6 + 1.0*0.3 + 1.0*0.1 = 1.0
+ // ancestorSim=1.0, siblingSim=1.0, formAssociationSim=1.0, depthSim=1.0
+ // structural = 1.0*0.5 + 1.0*0.2 + 1.0*0.2 + 1.0*0.1 = 1.0
expect(results[0]!.scores.structural).toBeCloseTo(1.0, 2);
});
@@ -124,14 +133,43 @@ describe("scoreCandidates", () => {
});
const candidate = makeCandidate({
ancestorRoles: ["nav", "list"],
+ ancestorTags: ["nav", "ul"],
siblingTokens: [],
domDepth: 3,
});
const results = scoreCandidates(sig, [candidate]);
- // ancestorSim=0 (disjoint), siblingSim=1 (both empty), depthSim=1
- // structural = 0*0.6 + 1*0.3 + 1*0.1 = 0.4
- expect(results[0]!.scores.structural).toBeCloseTo(0.4, 2);
+ // ancestorSim=0 (disjoint), siblingSim=1 (both empty), formAssociationSim=1, depthSim=1
+ // structural = 0*0.5 + 1*0.2 + 1*0.2 + 1*0.1 = 0.5
+ expect(results[0]!.scores.structural).toBeCloseTo(0.5, 2);
+ });
+
+ it("should reward matching form association in structural score", () => {
+ const sig = makeSignature({
+ structure: {
+ ancestorRoles: ["main", "form"],
+ ancestorTags: ["main", "form"],
+ siblingTokens: [],
+ formAssociation: "Email Address",
+ domDepth: 3,
+ },
+ });
+
+ const withMatchingLabel = makeCandidate({
+ index: 0,
+ formAssociation: "Email Address",
+ siblingTokens: [],
+ });
+ const withDifferentLabel = makeCandidate({
+ index: 1,
+ formAssociation: "Phone Number",
+ siblingTokens: [],
+ selectorHint: 'role=button[name="submit phone"]',
+ });
+
+ const [best, weaker] = scoreCandidates(sig, [withDifferentLabel, withMatchingLabel]);
+ expect(best.candidateIndex).toBe(0);
+ expect(best.scores.structural).toBeGreaterThan(weaker.scores.structural);
});
it("should compute spatial score using bbox center distance", () => {
@@ -148,6 +186,20 @@ describe("scoreCandidates", () => {
expect(results[0]!.scores.spatial).toBeCloseTo(1.0, 1);
});
+ it("documents fixed 1024x768 viewport normalization for spatial scoring", () => {
+ const sig = makeSignature({
+ spatial: {
+ bbox: { x: 0, y: 0, w: 10, h: 10 },
+ viewportRelative: { top: 0, left: 0 },
+ region: "main",
+ },
+ });
+ const candidate = makeCandidate({ bbox: { x: 512, y: 384, w: 10, h: 10 } });
+
+ const results = scoreCandidates(sig, [candidate]);
+ expect(results[0]!.scores.spatial).toBeCloseTo(0.5, 5);
+ });
+
it("should return spatial score of zero when bbox has no area", () => {
const sig = makeSignature({
spatial: {
@@ -196,6 +248,17 @@ describe("scoreCandidates", () => {
expect(spatialOnly[0]!.confidence).toBeCloseTo(spatialOnly[0]!.scores.spatial, 5);
});
+ it("should reject custom weights that do not sum to 1", () => {
+ const sig = makeSignature();
+ const candidate = makeCandidate();
+
+ expect(() =>
+ scoreCandidates(sig, [candidate], {
+ weights: { semantic: 1, structural: 1, spatial: 1 },
+ })
+ ).toThrow(/sum to 1/i);
+ });
+
it("should include role and accessibleName in results", () => {
const sig = makeSignature();
const candidate = makeCandidate({ role: "button", accessibleName: "Submit" });
@@ -204,4 +267,12 @@ describe("scoreCandidates", () => {
expect(results[0]!.role).toBe("button");
expect(results[0]!.accessibleName).toBe("Submit");
});
+
+ it("should include a human-readable explanation for each candidate", () => {
+ const sig = makeSignature();
+ const results = scoreCandidates(sig, [makeCandidate()]);
+ expect(results[0]!.explanation).toContain("semantic");
+ expect(results[0]!.explanation).toContain("structural");
+ expect(results[0]!.explanation).toContain("spatial");
+ });
});
diff --git a/src/__tests__/performance-budget.test.ts b/src/__tests__/performance-budget.test.ts
new file mode 100644
index 0000000..9fb8d0d
--- /dev/null
+++ b/src/__tests__/performance-budget.test.ts
@@ -0,0 +1,183 @@
+import { existsSync, readFileSync, statSync } from "node:fs";
+import { gzipSync } from "node:zlib";
+import { performance } from "node:perf_hooks";
+import { describe, expect, it } from "vitest";
+import { extractElements } from "../extractor.js";
+import { scoreCandidates } from "../matcher.js";
+import type { AccessibilitySnapshotResult, DOMSnapshotResult } from "../snapshot-types.js";
+import type { NormalizedElement, USEIDSignature } from "../types.js";
+
+const EXTRACTION_ELEMENT_BUDGET = 600;
+const EXTRACTION_TIME_BUDGET_MS = 2_000;
+
+const SCORING_CANDIDATE_BUDGET = 2_500;
+const SCORING_TIME_BUDGET_MS = 1_500;
+
+const BUNDLE_SIZE_BUDGETS = [
+ { path: "dist/index.js", rawBytes: 75_000 },
+ { path: "dist/index.cjs", rawBytes: 85_000 },
+] as const;
+const COMBINED_GZIP_BUDGET_BYTES = 45_000;
+
+function makeAXSnapshot(tree: unknown): AccessibilitySnapshotResult {
+ return { tree, hash: "perf-ax", serialized: "{}" };
+}
+
+function makeDOMSnapshot(snapshot: unknown): DOMSnapshotResult {
+ return { snapshot, hash: "perf-dom", serialized: "{}" };
+}
+
+function makeLargeButtonSnapshots(count: number): {
+ domSnapshot: DOMSnapshotResult;
+ accessibilitySnapshot: AccessibilitySnapshotResult;
+} {
+ const strings = ["html", "body", "button", ""];
+ const parentIndex = [-1, 0];
+ const nodeType = [1, 1];
+ const nodeName = [0, 1];
+ const nodeValue = [3, 3];
+ const backendNodeId = [1, 2];
+ const layoutNodeIndex = [0, 1];
+ const layoutBounds = [
+ [0, 0, 1024, 768],
+ [0, 0, 1024, 768],
+ ];
+
+ const axButtons = [];
+
+ for (let i = 0; i < count; i++) {
+ const name = `Action ${i.toString().padStart(4, "0")}`;
+ const nameIndex = strings.push(name) - 1;
+ const buttonIndex = nodeType.length;
+ const textIndex = buttonIndex + 1;
+
+ parentIndex.push(1, buttonIndex);
+ nodeType.push(1, 3);
+ nodeName.push(2, 3);
+ nodeValue.push(3, nameIndex);
+ backendNodeId.push(10_000 + buttonIndex, 10_000 + textIndex);
+ layoutNodeIndex.push(buttonIndex);
+ layoutBounds.push([20 + (i % 8) * 120, 40 + Math.floor(i / 8) * 48, 100, 36]);
+ axButtons.push({ role: "button", name });
+ }
+
+ return {
+ accessibilitySnapshot: makeAXSnapshot({
+ role: "WebArea",
+ name: "Performance Fixture",
+ children: axButtons,
+ }),
+ domSnapshot: makeDOMSnapshot({
+ documents: [
+ {
+ nodes: { parentIndex, nodeType, nodeName, nodeValue, backendNodeId },
+ layout: { nodeIndex: layoutNodeIndex, bounds: layoutBounds },
+ },
+ ],
+ strings,
+ }),
+ };
+}
+
+function makeSignature(): USEIDSignature {
+ return {
+ version: 1,
+ origin: "https://example.com",
+ pagePath: "/page",
+ semantic: {
+ role: "button",
+ accessibleName: "Target Action",
+ },
+ structure: {
+ ancestorRoles: ["webarea"],
+ ancestorTags: ["html", "body"],
+ siblingTokens: ["nearby action"],
+ domDepth: 1,
+ },
+ spatial: {
+ bbox: { x: 120, y: 160, w: 100, h: 36 },
+ viewportRelative: { top: 0.21, left: 0.12 },
+ region: "unknown",
+ },
+ stability: { confidence: 0.9 },
+ hash: "perf-signature",
+ };
+}
+
+function makeScoringCandidates(count: number): NormalizedElement[] {
+ const targetIndex = Math.floor(count / 2);
+
+ return Array.from({ length: count }, (_, index) => ({
+ index,
+ role: "button",
+ accessibleName: index === targetIndex ? "Target Action" : `Other Action ${index}`,
+ tagName: "button",
+ ancestorRoles: ["webarea"],
+ ancestorTags: ["html", "body"],
+ siblingTokens: index === targetIndex ? ["nearby action"] : [`other ${index}`],
+ domDepth: 1,
+ bbox:
+ index === targetIndex
+ ? { x: 120, y: 160, w: 100, h: 36 }
+ : { x: 20 + (index % 20) * 40, y: 40 + Math.floor(index / 20) * 20, w: 100, h: 36 },
+ region: "unknown",
+ selectorHint:
+ index === targetIndex
+ ? 'role=button[name="target action"]'
+ : `role=button[name="other action ${index}"]`,
+ }));
+}
+
+describe("performance budgets", () => {
+ it("extracts a large DOM and accessibility snapshot within the hot-path budget", () => {
+ const { domSnapshot, accessibilitySnapshot } = makeLargeButtonSnapshots(
+ EXTRACTION_ELEMENT_BUDGET
+ );
+
+ const started = performance.now();
+ const elements = extractElements(domSnapshot, accessibilitySnapshot);
+ const elapsed = performance.now() - started;
+
+ expect(elements).toHaveLength(EXTRACTION_ELEMENT_BUDGET);
+ expect(elapsed, `${EXTRACTION_ELEMENT_BUDGET} element extraction took ${elapsed}ms`).toBeLessThan(
+ EXTRACTION_TIME_BUDGET_MS
+ );
+ });
+
+ it("scores many same-role candidates within the hot-path budget", () => {
+ const signature = makeSignature();
+ const candidates = makeScoringCandidates(SCORING_CANDIDATE_BUDGET);
+
+ const started = performance.now();
+ const results = scoreCandidates(signature, candidates);
+ const elapsed = performance.now() - started;
+
+ expect(results).toHaveLength(SCORING_CANDIDATE_BUDGET);
+ expect(results[0]!.selectorHint).toBe('role=button[name="target action"]');
+ expect(elapsed, `${SCORING_CANDIDATE_BUDGET} candidate scoring took ${elapsed}ms`).toBeLessThan(
+ SCORING_TIME_BUDGET_MS
+ );
+ });
+
+ it("keeps built distribution artifacts within bundle-size budgets", () => {
+ const missingArtifacts = BUNDLE_SIZE_BUDGETS.map((entry) => entry.path).filter(
+ (path) => !existsSync(path)
+ );
+ expect(
+ missingArtifacts,
+ "run npm run build before npm test so dist bundle budgets can be checked"
+ ).toEqual([]);
+
+ let combinedGzipBytes = 0;
+ for (const budget of BUNDLE_SIZE_BUDGETS) {
+ const rawBytes = statSync(budget.path).size;
+ const source = readFileSync(budget.path);
+ combinedGzipBytes += gzipSync(source).byteLength;
+ expect(rawBytes, `${budget.path} raw bundle size`).toBeLessThanOrEqual(budget.rawBytes);
+ }
+
+ expect(combinedGzipBytes, "combined gzip bundle size").toBeLessThanOrEqual(
+ COMBINED_GZIP_BUDGET_BYTES
+ );
+ });
+});
diff --git a/src/__tests__/realistic-fixtures.test.ts b/src/__tests__/realistic-fixtures.test.ts
new file mode 100644
index 0000000..be1cd4f
--- /dev/null
+++ b/src/__tests__/realistic-fixtures.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "vitest";
+import { buildUSEID } from "../builder.js";
+import { resolveUSEID } from "../resolver.js";
+import {
+ closedShadowFixture,
+ duplicateSameNameFixture,
+ missingRoleAndNameFixture,
+ openShadowFixture,
+ realisticPageUrl,
+ sameOriginFramePath,
+ sameOriginIframeFixture,
+ wrapperLayoutChurnFixture,
+} from "./fixtures/realistic-snapshots.js";
+
+describe("realistic snapshot fixtures", () => {
+ it("resolves the same target under wrapper and layout churn", () => {
+ const signature = buildUSEID({
+ domSnapshot: wrapperLayoutChurnFixture.before.domSnapshot,
+ accessibilitySnapshot: wrapperLayoutChurnFixture.before.accessibilitySnapshot,
+ elementIndex: 0,
+ pageUrl: realisticPageUrl,
+ });
+
+ const result = resolveUSEID({
+ signature,
+ domSnapshot: wrapperLayoutChurnFixture.after.domSnapshot,
+ accessibilitySnapshot: wrapperLayoutChurnFixture.after.accessibilitySnapshot,
+ pageUrl: realisticPageUrl,
+ });
+
+ expect(result.resolved).toBe(true);
+ if (result.resolved) {
+ expect(result.selectorHint).toBe('role=button[name="continue"]');
+ expect(result.confidence).toBeGreaterThanOrEqual(0.85);
+ }
+ });
+
+ it("abstains on duplicate same-role same-name candidates without sufficient margin", () => {
+ const signature = buildUSEID({
+ domSnapshot: duplicateSameNameFixture.before.domSnapshot,
+ accessibilitySnapshot: duplicateSameNameFixture.before.accessibilitySnapshot,
+ elementIndex: 0,
+ pageUrl: realisticPageUrl,
+ });
+
+ const result = resolveUSEID({
+ signature,
+ domSnapshot: duplicateSameNameFixture.after.domSnapshot,
+ accessibilitySnapshot: duplicateSameNameFixture.after.accessibilitySnapshot,
+ pageUrl: realisticPageUrl,
+ config: { threshold: 0.7 },
+ });
+
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("ambiguous_match");
+ expect(result.candidates).toHaveLength(3);
+ }
+ });
+
+ it("abstains when the target loses both role and accessible name", () => {
+ const signature = buildUSEID({
+ domSnapshot: wrapperLayoutChurnFixture.before.domSnapshot,
+ accessibilitySnapshot: wrapperLayoutChurnFixture.before.accessibilitySnapshot,
+ elementIndex: 0,
+ pageUrl: realisticPageUrl,
+ });
+
+ const result = resolveUSEID({
+ signature,
+ domSnapshot: missingRoleAndNameFixture.domSnapshot,
+ accessibilitySnapshot: missingRoleAndNameFixture.accessibilitySnapshot,
+ pageUrl: realisticPageUrl,
+ });
+
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("no_candidates");
+ }
+ });
+
+ it("uses same-origin framePath binding for iframe targets", () => {
+ const signature = buildUSEID({
+ domSnapshot: sameOriginIframeFixture.domSnapshot,
+ accessibilitySnapshot: sameOriginIframeFixture.accessibilitySnapshot,
+ elementIndex: 0,
+ pageUrl: realisticPageUrl,
+ framePath: sameOriginFramePath,
+ });
+
+ const resolved = resolveUSEID({
+ signature,
+ domSnapshot: sameOriginIframeFixture.domSnapshot,
+ accessibilitySnapshot: sameOriginIframeFixture.accessibilitySnapshot,
+ pageUrl: realisticPageUrl,
+ framePath: sameOriginFramePath,
+ });
+ const missingFramePath = resolveUSEID({
+ signature,
+ domSnapshot: sameOriginIframeFixture.domSnapshot,
+ accessibilitySnapshot: sameOriginIframeFixture.accessibilitySnapshot,
+ pageUrl: realisticPageUrl,
+ });
+
+ expect(resolved.resolved).toBe(true);
+ if (resolved.resolved) {
+ expect(resolved.framePath).toEqual(sameOriginFramePath);
+ }
+ expect(missingFramePath.resolved).toBe(false);
+ if (!missingFramePath.resolved) {
+ expect(missingFramePath.abstentionReason).toBe("binding_mismatch");
+ }
+ });
+
+ it("supports open shadow DOM when snapshots expose the internal target", () => {
+ const signature = buildUSEID({
+ domSnapshot: openShadowFixture.domSnapshot,
+ accessibilitySnapshot: openShadowFixture.accessibilitySnapshot,
+ elementIndex: 0,
+ pageUrl: realisticPageUrl,
+ });
+
+ const result = resolveUSEID({
+ signature,
+ domSnapshot: openShadowFixture.domSnapshot,
+ accessibilitySnapshot: openShadowFixture.accessibilitySnapshot,
+ pageUrl: realisticPageUrl,
+ });
+
+ expect(result.resolved).toBe(true);
+ if (result.resolved) {
+ expect(result.selectorHint).toBe('role=button[name="shadow action"]');
+ expect(result.scores.spatial).toBeGreaterThan(0);
+ }
+ });
+
+ it("abstains for closed shadow DOM targets without exposed DOM/layout evidence", () => {
+ const signature = buildUSEID({
+ domSnapshot: closedShadowFixture.domSnapshot,
+ accessibilitySnapshot: closedShadowFixture.accessibilitySnapshot,
+ elementIndex: 0,
+ pageUrl: realisticPageUrl,
+ });
+
+ const result = resolveUSEID({
+ signature,
+ domSnapshot: closedShadowFixture.domSnapshot,
+ accessibilitySnapshot: closedShadowFixture.accessibilitySnapshot,
+ pageUrl: realisticPageUrl,
+ });
+
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("below_threshold");
+ }
+ });
+});
diff --git a/src/__tests__/resolver.test.ts b/src/__tests__/resolver.test.ts
index 52cf22a..373447f 100644
--- a/src/__tests__/resolver.test.ts
+++ b/src/__tests__/resolver.test.ts
@@ -108,6 +108,28 @@ describe("resolveUSEID", () => {
expect(result.abstentionReason).toBe("binding_mismatch");
}
});
+
+ it("should abstain with no_candidates when no element has the expected role", () => {
+ const domSnapshot = makeDOMSnapshot(null);
+ const accessibilitySnapshot = makeAXSnapshot({
+ role: "WebArea",
+ name: "Page",
+ children: [{ role: "link", name: "Home" }],
+ });
+
+ const signature = makeSignature();
+ const result = resolveUSEID({
+ signature,
+ domSnapshot,
+ accessibilitySnapshot,
+ pageUrl,
+ });
+
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("no_candidates");
+ }
+ });
});
// ── compareUSEID ────────────────────────────────────────────────────────────
@@ -157,14 +179,136 @@ describe("compareUSEID", () => {
it("should return 1 when names differ only by case/whitespace", () => {
const a = makeSignature({
hash: "hash-a",
- semantic: { role: "button", accessibleName: "Submit Form" },
+ semantic: {
+ role: "button",
+ accessibleName: "Submit Form",
+ accessibleDescription: "Primary checkout action",
+ },
+ structure: {
+ ancestorRoles: ["main", "form"],
+ ancestorTags: ["main", "form"],
+ siblingTokens: ["cancel"],
+ formAssociation: "Checkout",
+ domDepth: 1,
+ },
+ spatial: {
+ bbox: { x: 0, y: 0, w: 0, h: 0 },
+ viewportRelative: { top: 0, left: 0 },
+ region: "main",
+ },
});
const b = makeSignature({
hash: "hash-b",
- semantic: { role: "button", accessibleName: "submit form" },
+ semantic: {
+ role: "button",
+ accessibleName: "submit form",
+ accessibleDescription: "primary checkout action",
+ },
+ structure: {
+ ancestorRoles: ["main", "form"],
+ ancestorTags: ["main", "form"],
+ siblingTokens: ["cancel"],
+ formAssociation: "checkout",
+ domDepth: 1,
+ },
+ spatial: {
+ bbox: { x: 0, y: 0, w: 0, h: 0 },
+ viewportRelative: { top: 0, left: 0 },
+ region: "main",
+ },
});
expect(compareUSEID(a, b)).toBe(1);
});
+
+ it("should return 0 when frame paths differ", () => {
+ const a = makeSignature({
+ framePath: [{ url: "https://example.com/frame-a", index: 0 }],
+ });
+ const b = makeSignature({
+ framePath: [{ url: "https://example.com/frame-b", index: 0 }],
+ });
+ expect(compareUSEID(a, b)).toBe(0);
+ });
+
+ it("should return 0.5 when semantic core matches but context diverges", () => {
+ const a = makeSignature({
+ hash: "hash-a",
+ semantic: {
+ role: "button",
+ accessibleName: "Submit",
+ accessibleDescription: "Primary action",
+ },
+ structure: {
+ ancestorRoles: ["main", "checkout-form"],
+ ancestorTags: ["main", "form"],
+ siblingTokens: ["cancel"],
+ formAssociation: "Checkout",
+ domDepth: 1,
+ },
+ spatial: {
+ bbox: { x: 0, y: 0, w: 0, h: 0 },
+ viewportRelative: { top: 0, left: 0 },
+ region: "main",
+ },
+ });
+ const b = makeSignature({
+ hash: "hash-b",
+ semantic: {
+ role: "button",
+ accessibleName: "Submit",
+ accessibleDescription: "Primary action",
+ },
+ structure: {
+ ancestorRoles: ["nav"],
+ ancestorTags: ["nav"],
+ siblingTokens: ["home"],
+ formAssociation: "Header",
+ domDepth: 1,
+ },
+ spatial: {
+ bbox: { x: 0, y: 0, w: 0, h: 0 },
+ viewportRelative: { top: 0, left: 0 },
+ region: "nav",
+ },
+ });
+
+ expect(compareUSEID(a, b)).toBe(0.5);
+ });
+
+ it("should return 0.5 when same name and role have different structural context", () => {
+ const a = makeSignature({
+ hash: "hash-a",
+ semantic: { role: "button", accessibleName: "Open" },
+ structure: {
+ ancestorRoles: ["navigation"],
+ ancestorTags: ["nav"],
+ siblingTokens: ["close"],
+ domDepth: 2,
+ },
+ spatial: {
+ bbox: { x: 0, y: 0, w: 0, h: 0 },
+ viewportRelative: { top: 0, left: 0 },
+ region: "nav",
+ },
+ });
+ const b = makeSignature({
+ hash: "hash-b",
+ semantic: { role: "button", accessibleName: "Open" },
+ structure: {
+ ancestorRoles: ["main"],
+ ancestorTags: ["main"],
+ siblingTokens: ["save"],
+ domDepth: 3,
+ },
+ spatial: {
+ bbox: { x: 0, y: 0, w: 0, h: 0 },
+ viewportRelative: { top: 0, left: 0 },
+ region: "main",
+ },
+ });
+
+ expect(compareUSEID(a, b)).toBe(0.5);
+ });
});
// ── explainResolution ───────────────────────────────────────────────────────
@@ -176,6 +320,8 @@ describe("explainResolution", () => {
selectorHint: 'role=button[name="submit"]',
candidateIndex: 0,
confidence: 0.95,
+ scores: { semantic: 1, structural: 0.9, spatial: 0.8 },
+ scoreGap: 0.2,
explanation: 'Matched button[name="Submit"] with confidence 0.950',
};
@@ -193,6 +339,7 @@ describe("explainResolution", () => {
selectorHint: 'role=button[name="submit"]',
confidence: 0.5,
scores: { semantic: 0.6, structural: 0.4, spatial: 0.3 },
+ explanation: "semantic partial (0.600), structural weak (0.400), spatial weak (0.300)",
role: "button",
accessibleName: "Submit",
},
@@ -210,7 +357,7 @@ describe("explainResolution", () => {
const result: ResolveResult = {
resolved: false,
candidates: [],
- explanation: "No candidate elements found matching the signature role",
+ explanation: 'No candidate elements found for role="button" name="Submit"',
abstentionReason: "no_candidates",
};
@@ -263,6 +410,51 @@ describe("redactUSEID", () => {
expect(redacted.structure.formAssociation).toBeUndefined();
});
+ it("should compute redacted hash without redacted context fields", () => {
+ const first = makeSignature({
+ semantic: {
+ role: "button",
+ accessibleName: "Submit",
+ accessibleDescription: "Primary checkout action",
+ },
+ structure: {
+ ancestorRoles: ["main"],
+ ancestorTags: ["main"],
+ siblingTokens: ["email", "password"],
+ formAssociation: "Email Address",
+ domDepth: 2,
+ },
+ });
+ const second = makeSignature({
+ semantic: {
+ role: "button",
+ accessibleName: "Submit",
+ accessibleDescription: "Sensitive billing action",
+ },
+ structure: {
+ ancestorRoles: ["main"],
+ ancestorTags: ["main"],
+ siblingTokens: ["billing", "phone"],
+ formAssociation: "Phone Number",
+ domDepth: 2,
+ },
+ });
+
+ const redactedFirst = redactUSEID(first);
+ const redactedSecond = redactUSEID(second);
+
+ expect(redactedFirst.semantic.accessibleName).toMatch(/^\[redacted:[a-f0-9]{16}\]$/);
+ expect(redactedFirst.semantic.accessibleName).not.toContain("Submit");
+ expect(redactedFirst.semantic.accessibleDescription).toBe("[redacted]");
+ expect(redactedFirst.structure.siblingTokens).toEqual([]);
+ expect(redactedFirst.structure.formAssociation).toBeUndefined();
+ expect(redactedSecond.semantic.accessibleName).toBe(redactedFirst.semantic.accessibleName);
+ expect(redactedSecond.semantic.accessibleDescription).toBe("[redacted]");
+ expect(redactedSecond.structure.siblingTokens).toEqual([]);
+ expect(redactedSecond.structure.formAssociation).toBeUndefined();
+ expect(redactedFirst.hash).toBe(redactedSecond.hash);
+ });
+
it("should produce deterministic hash for same input", () => {
const sig = makeSignature();
const r1 = redactUSEID(sig);
diff --git a/src/__tests__/safety.test.ts b/src/__tests__/safety.test.ts
index 633e0c4..3bb56ff 100644
--- a/src/__tests__/safety.test.ts
+++ b/src/__tests__/safety.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { checkBinding, applySafetyGate } from "../safety.js";
+import { USEIDAbstentionReasonSchema } from "../types.js";
import type { USEIDSignature, CandidateResult } from "../types.js";
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -36,6 +37,7 @@ function makeCandidate(overrides: Partial = {}): CandidateResul
selectorHint: 'role=button[name="submit"]',
confidence: 0.95,
scores: { semantic: 1.0, structural: 0.9, spatial: 0.8 },
+ explanation: "semantic exact (1.000), structural strong (0.900), spatial nearby (0.800)",
role: "button",
accessibleName: "Submit",
...overrides,
@@ -169,6 +171,72 @@ describe("applySafetyGate", () => {
}
});
+ it("should abstain with ambiguous_match for duplicate same-name candidates", () => {
+ const sig = makeSignature();
+ const first = makeCandidate({
+ candidateIndex: 0,
+ confidence: 0.93,
+ selectorHint: 'role=button[name="submit"]',
+ accessibleName: "Submit",
+ });
+ const duplicate = makeCandidate({
+ candidateIndex: 1,
+ confidence: 0.91,
+ selectorHint: 'role=button[name="submit"]',
+ accessibleName: "Submit",
+ });
+
+ const result = applySafetyGate([first, duplicate], sig, pageUrl);
+
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("ambiguous_match");
+ }
+ });
+
+ it("should abstain with below_threshold for weak evidence from incomplete DOM layout", () => {
+ const sig = makeSignature();
+ const weak = makeCandidate({
+ confidence: 0.8,
+ scores: { semantic: 1, structural: 1, spatial: 0 },
+ });
+
+ const result = applySafetyGate([weak], sig, pageUrl);
+
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("below_threshold");
+ }
+ });
+
+ it("should abstain with below_threshold for missing accessible-name evidence", () => {
+ const sig = makeSignature({ semantic: { role: "button", accessibleName: "" } });
+ const unnamed = makeCandidate({
+ confidence: 0.3,
+ scores: { semantic: 0, structural: 1, spatial: 0 },
+ accessibleName: "",
+ selectorHint: "role=button[index=0]",
+ });
+
+ const result = applySafetyGate([unnamed], sig, pageUrl);
+
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("below_threshold");
+ }
+ });
+
+ it("should abstain when the runner-up is slightly below threshold but still too close", () => {
+ const sig = makeSignature();
+ const first = makeCandidate({ candidateIndex: 0, confidence: 0.86 });
+ const second = makeCandidate({ candidateIndex: 1, confidence: 0.84 });
+ const result = applySafetyGate([first, second], sig, pageUrl);
+ expect(result.resolved).toBe(false);
+ if (!result.resolved) {
+ expect(result.abstentionReason).toBe("ambiguous_match");
+ }
+ });
+
it("should resolve when top candidate is above threshold with sufficient margin", () => {
const sig = makeSignature();
const top = makeCandidate({ candidateIndex: 0, confidence: 0.95 });
@@ -179,6 +247,8 @@ describe("applySafetyGate", () => {
expect(result.confidence).toBe(0.95);
expect(result.selectorHint).toBe('role=button[name="submit"]');
expect(result.candidateIndex).toBe(0);
+ expect(result.scores.semantic).toBe(1);
+ expect(result.scoreGap).toBeCloseTo(0.25, 5);
}
});
@@ -220,4 +290,34 @@ describe("applySafetyGate", () => {
expect(result.framePath).toEqual(framePath);
}
});
+
+ it("should emit only stable abstention reasons", () => {
+ const results = [
+ applySafetyGate([makeCandidate()], makeSignature({ origin: "https://other.com" }), pageUrl),
+ applySafetyGate([], makeSignature(), pageUrl),
+ applySafetyGate([makeCandidate({ confidence: 0.5 })], makeSignature(), pageUrl),
+ applySafetyGate(
+ [
+ makeCandidate({ candidateIndex: 0, confidence: 0.92 }),
+ makeCandidate({ candidateIndex: 1, confidence: 0.9 }),
+ ],
+ makeSignature(),
+ pageUrl
+ ),
+ ];
+
+ const reasons = results.map((result) => {
+ expect(result.resolved).toBe(false);
+ if (result.resolved) throw new Error("expected abstention");
+ expect(USEIDAbstentionReasonSchema.safeParse(result.abstentionReason).success).toBe(true);
+ return result.abstentionReason;
+ });
+
+ expect(reasons).toEqual([
+ "binding_mismatch",
+ "no_candidates",
+ "below_threshold",
+ "ambiguous_match",
+ ]);
+ });
});
diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts
index c9ada8a..89774aa 100644
--- a/src/__tests__/types.test.ts
+++ b/src/__tests__/types.test.ts
@@ -2,10 +2,12 @@ import { describe, it, expect } from "vitest";
import {
USEIDSignatureSchema,
USEIDConfigSchema,
+ USEIDAbstentionReasonSchema,
ResolveResultSchema,
CandidateResultSchema,
FramePathEntrySchema,
SemanticRegionSchema,
+ type USEIDAbstentionReason,
} from "../types.js";
const validSignature = {
@@ -104,6 +106,57 @@ describe("USEIDConfigSchema", () => {
const result = USEIDConfigSchema.safeParse({ threshold: 1.5 });
expect(result.success).toBe(false);
});
+
+ it("accepts custom weights that sum to 1", () => {
+ const result = USEIDConfigSchema.safeParse({
+ weights: { semantic: 0.7, structural: 0.2, spatial: 0.1 },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects custom weights that do not sum to 1", () => {
+ const result = USEIDConfigSchema.safeParse({
+ weights: { semantic: 1, structural: 1, spatial: 1 },
+ });
+ expect(result.success).toBe(false);
+ });
+});
+
+describe("USEIDAbstentionReasonSchema", () => {
+ const reasons = [
+ "binding_mismatch",
+ "no_candidates",
+ "below_threshold",
+ "ambiguous_match",
+ ] as const satisfies readonly USEIDAbstentionReason[];
+
+ function labelReason(reason: USEIDAbstentionReason): string {
+ switch (reason) {
+ case "binding_mismatch":
+ return "Binding mismatch";
+ case "no_candidates":
+ return "No candidates";
+ case "below_threshold":
+ return "Below threshold";
+ case "ambiguous_match":
+ return "Ambiguous match";
+ default: {
+ const exhaustive: never = reason;
+ return exhaustive;
+ }
+ }
+ }
+
+ it("accepts every stable abstention reason", () => {
+ for (const reason of reasons) {
+ expect(USEIDAbstentionReasonSchema.safeParse(reason).success).toBe(true);
+ expect(labelReason(reason)).toBeTruthy();
+ }
+ });
+
+ it("rejects unknown abstention reasons", () => {
+ expect(USEIDAbstentionReasonSchema.safeParse("timeout").success).toBe(false);
+ });
});
describe("ResolveResultSchema", () => {
@@ -113,6 +166,7 @@ describe("ResolveResultSchema", () => {
selectorHint: 'role=button[name="ok"]',
candidateIndex: 0,
confidence: 0.95,
+ scores: { semantic: 1, structural: 0.8, spatial: 0.7 },
explanation: "Matched",
});
expect(result.success).toBe(true);
@@ -127,6 +181,40 @@ describe("ResolveResultSchema", () => {
});
expect(result.success).toBe(true);
});
+
+ it("rejects unknown abstention reason", () => {
+ const result = ResolveResultSchema.safeParse({
+ resolved: false,
+ candidates: [],
+ explanation: "No match",
+ abstentionReason: "timeout",
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects resolved confidence above 1", () => {
+ const result = ResolveResultSchema.safeParse({
+ resolved: true,
+ selectorHint: 'role=button[name="ok"]',
+ candidateIndex: 0,
+ confidence: 1.1,
+ scores: { semantic: 1, structural: 0.8, spatial: 0.7 },
+ explanation: "Matched",
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects resolved score above 1", () => {
+ const result = ResolveResultSchema.safeParse({
+ resolved: true,
+ selectorHint: 'role=button[name="ok"]',
+ candidateIndex: 0,
+ confidence: 0.95,
+ scores: { semantic: 1.1, structural: 0.8, spatial: 0.7 },
+ explanation: "Matched",
+ });
+ expect(result.success).toBe(false);
+ });
});
describe("CandidateResultSchema", () => {
@@ -136,11 +224,38 @@ describe("CandidateResultSchema", () => {
selectorHint: 'role=button[name="ok"]',
confidence: 0.9,
scores: { semantic: 0.95, structural: 0.8, spatial: 0.7 },
+ explanation: "semantic exact (0.950), structural strong (0.800), spatial plausible (0.700)",
role: "button",
accessibleName: "ok",
});
expect(result.success).toBe(true);
});
+
+ it("rejects candidate confidence outside 0-1", () => {
+ const result = CandidateResultSchema.safeParse({
+ candidateIndex: 0,
+ selectorHint: 'role=button[name="ok"]',
+ confidence: -0.1,
+ scores: { semantic: 0.95, structural: 0.8, spatial: 0.7 },
+ explanation: "semantic exact (0.950), structural strong (0.800), spatial plausible (0.700)",
+ role: "button",
+ accessibleName: "ok",
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects candidate scores outside 0-1", () => {
+ const result = CandidateResultSchema.safeParse({
+ candidateIndex: 0,
+ selectorHint: 'role=button[name="ok"]',
+ confidence: 0.9,
+ scores: { semantic: 0.95, structural: 0.8, spatial: -0.1 },
+ explanation: "semantic exact (0.950), structural strong (0.800), spatial plausible (0.700)",
+ role: "button",
+ accessibleName: "ok",
+ });
+ expect(result.success).toBe(false);
+ });
});
describe("FramePathEntrySchema", () => {
diff --git a/src/builder.ts b/src/builder.ts
index 4d757df..cd81dad 100644
--- a/src/builder.ts
+++ b/src/builder.ts
@@ -7,8 +7,8 @@ import type { DOMSnapshotResult, AccessibilitySnapshotResult } from "./snapshot-
import type { USEIDSignature, USEIDConfig, FramePathEntry } from "./types.js";
import { USEIDConfigSchema } from "./types.js";
import { extractElements } from "./extractor.js";
-import { normalizeAccessibleName } from "./canonicalizer.js";
import { USEID_VERSION, DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT } from "./constants.js";
+import { buildIdentityFingerprintInput } from "./fingerprint.js";
export interface BuildUSEIDOptions {
domSnapshot: DOMSnapshotResult;
@@ -63,14 +63,9 @@ export function buildUSEID(opts: BuildUSEIDOptions): USEIDSignature {
if (element.siblingTokens.length > 0) confidence += 0.1;
confidence = Math.min(confidence, 1);
- // Compute hash of canonical (origin + pagePath + semantic core)
- const hashInput = [
- origin,
- pagePath,
- element.role,
- normalizeAccessibleName(element.accessibleName),
- ].join("|");
- const hash = createHash("sha256").update(hashInput).digest("hex");
+ const hash = createHash("sha256")
+ .update(buildIdentityFingerprintInput(origin, pagePath, opts.framePath, element))
+ .digest("hex");
return {
version: USEID_VERSION,
diff --git a/src/candidate.ts b/src/candidate.ts
index bb5aabe..33d8d9f 100644
--- a/src/candidate.ts
+++ b/src/candidate.ts
@@ -7,7 +7,8 @@ import { normalizeRole } from "./canonicalizer.js";
/**
* Generate candidate elements that could match a uSEID signature.
- * Strategy: start with role match, then widen to all elements if no role matches.
+ * Strategy: only consider same-role elements. uSEID abstains when the expected
+ * role is absent instead of widening to unrelated elements.
*/
export function generateCandidates(
signature: USEIDSignature,
@@ -15,10 +16,5 @@ export function generateCandidates(
): NormalizedElement[] {
const targetRole = normalizeRole(signature.semantic.role);
- // Primary: filter by matching role
- const roleMatches = elements.filter((e) => e.role === targetRole);
- if (roleMatches.length > 0) return roleMatches;
-
- // Fallback: return all elements (matcher will score them low)
- return elements;
+ return elements.filter((e) => e.role === targetRole);
}
diff --git a/src/extractor.ts b/src/extractor.ts
index 5caf62f..ac96e93 100644
--- a/src/extractor.ts
+++ b/src/extractor.ts
@@ -10,7 +10,12 @@
import type { DOMSnapshotResult, AccessibilitySnapshotResult } from "./snapshot-types.js";
import type { NormalizedElement, SemanticRegion } from "./types.js";
-import { normalizeAccessibleName, normalizeRole, normalizeTag } from "./canonicalizer.js";
+import {
+ nameSimilarity,
+ normalizeAccessibleName,
+ normalizeRole,
+ normalizeTag,
+} from "./canonicalizer.js";
import {
LANDMARK_ROLE_MAP,
MAX_ANCESTOR_LEVELS,
@@ -89,6 +94,7 @@ export function extractElements(
// Parse DOM snapshot for layout and structural data
const domStructure = extractDOMStructure(domSnapshot.snapshot as CDPDOMSnapshot | null);
+ const domMatchIndex = buildDOMMatchIndex(domStructure);
// Build normalized elements
const elements: NormalizedElement[] = [];
@@ -104,7 +110,7 @@ export function extractElements(
const normalizedName = normalizeAccessibleName(ax.name);
// Find matching DOM node for spatial data
- const domMatch = findDOMMatch(ax, domStructure);
+ const domMatch = findDOMMatch(ax, domMatchIndex);
const bbox = domMatch?.bounds ?? { x: 0, y: 0, w: 0, h: 0 };
// Determine semantic region from ancestor roles
@@ -196,6 +202,12 @@ interface DOMNodeInfo {
bounds?: { x: number; y: number; w: number; h: number };
ancestorTags: string[];
labelText?: string;
+ textContent?: string;
+}
+
+interface DOMMatchIndex {
+ byTag: Map;
+ byTagAndName: Map;
}
function extractDOMStructure(snapshot: CDPDOMSnapshot | null): DOMNodeInfo[] {
@@ -208,6 +220,7 @@ function extractDOMStructure(snapshot: CDPDOMSnapshot | null): DOMNodeInfo[] {
const result: DOMNodeInfo[] = [];
const layoutMap = extractLayoutMapFromDoc(doc);
+ const childrenMap = buildChildrenMap(nodes.parentIndex ?? []);
for (let i = 0; i < nodeCount; i++) {
// Only process element nodes (nodeType 1)
@@ -235,7 +248,8 @@ function extractDOMStructure(snapshot: CDPDOMSnapshot | null): DOMNodeInfo[] {
const bounds = layoutMap.get(i);
// Check for associated label (simplified: look for