diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe2fc52..9521e2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,92 @@ 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 release policy + run: node scripts/check-release-policy.mjs + - 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" + VERSION="$PKG_VERSION" node - <<'NODE' + const version = process.env.VERSION; + const stableSemverPattern = /^\d+\.\d+\.\d+$/; + const rcSemverPattern = /^\d+\.\d+\.\d+-rc\.\d+$/; + + if (!stableSemverPattern.test(version) && !rcSemverPattern.test(version)) { + console.error(`Release tags must match x.y.z or x.y.z-rc.N, got ${version}`); + process.exit(1); + } + NODE + - 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 run test:perf + - 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 +102,29 @@ jobs: - run: npm ci - run: npm run build - - run: npm run typecheck - - run: npm test - - run: npm publish + - name: Resolve release policy + id: release_policy + run: | + VERSION="$(node -p "require('./package.json').version")" + VERSION="$VERSION" node - <<'NODE' >> "$GITHUB_OUTPUT" + const version = process.env.VERSION; + const stableSemverPattern = /^\d+\.\d+\.\d+$/; + const rcSemverPattern = /^\d+\.\d+\.\d+-rc\.\d+$/; + + if (rcSemverPattern.test(version)) { + console.log("npm_tag=rc"); + console.log("github_prerelease=true"); + } else if (stableSemverPattern.test(version)) { + console.log("npm_tag=latest"); + console.log("github_prerelease=false"); + } else { + console.error(`Release tags must match x.y.z or x.y.z-rc.N, got ${version}`); + process.exit(1); + } + NODE + - name: Re-check release policy before publish + run: node scripts/check-release-policy.mjs + - run: npm publish --access public --provenance --tag "${{ steps.release_policy.outputs.npm_tag }}" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -32,3 +132,4 @@ jobs: uses: softprops/action-gh-release@v2 with: generate_release_notes: true + prerelease: ${{ steps.release_policy.outputs.github_prerelease }} 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..0b89e52 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +## [1.0.0-rc.1] - 2026-05-04 + +`@pyyush/useid@1.0.0-rc.1` is published on npm under the `rc` dist-tag. The npm `latest` dist-tag remains `0.1.0` as of May 5, 2026, so install this release candidate with `npm install @pyyush/useid@rc`. + +### Runtime support + +- The supported and tested runtime matrix for this RC is Node.js 20 and 22. +- The package `engines.node` claim is intentionally narrowed to `^20.0.0 || ^22.0.0` until extraction budget evidence covers newer Node versions. +- CI and release verification run the same Node 20/22 matrix. + +### 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 `1.0.0-rc.1` API, 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 + +Pre-RC local release baseline used during 1.0 planning. This version was not the npm `latest` at the time of the planning work and is superseded by `1.0.0-rc.1`. + +### 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/RC_VALIDATION.md b/RC_VALIDATION.md new file mode 100644 index 0000000..3bda670 --- /dev/null +++ b/RC_VALIDATION.md @@ -0,0 +1,211 @@ +# uSEID RC Validation Handoff + +This handoff validates `@pyyush/useid@1.0.0-rc.1` before the stable `1.0.0` release. The RC exists on npm under the `rc` dist-tag and as a GitHub prerelease. + +## Version And RC Naming Policy + +The repository package version was bumped to `1.0.0-rc.1` in the RC version-bump commit and tagged as `v1.0.0-rc.1`. Later RCs use `1.0.0-rc.2`, `1.0.0-rc.3`, and so on. The release workflow rejects tags that do not exactly match `package.json`, accepts only stable `x.y.z` or RC `x.y.z-rc.N` versions, publishes RCs with the npm `rc` dist-tag, and marks RC GitHub releases as prereleases. The final stable release uses `1.0.0`, tag `v1.0.0`, npm dist-tag `latest`, and a non-prerelease GitHub release. + +## RC Artifact + +- RC version: `1.0.0-rc.1` +- npm dist-tag install: `npm install @pyyush/useid@rc` +- explicit npm version install: `npm install @pyyush/useid@1.0.0-rc.1` +- tarball URL: `https://registry.npmjs.org/@pyyush/useid/-/useid-1.0.0-rc.1.tgz` +- npm integrity: `sha512-G8wvm6PIQlIH0rvhLJNC43pRiGfsKQHiTqyC73YKnhzzlKaZ0aA6ewZDeF73Asds1la7t9s4HgKinBwcfVhxuA==` +- npm shasum: `e35a3a16386110137f8e116435be9bf9858636c9` +- Git tag: `v1.0.0-rc.1` +- GitHub prerelease: `https://github.com/pyyush/useid/releases/tag/v1.0.0-rc.1` +- Release workflow: `https://github.com/pyyush/useid/actions/runs/25339009937` + +`latest` remains `0.1.0`; `rc` points at `1.0.0-rc.1`. + +## Release Owner Evidence + +Remote GitHub settings for `pyyush/useid` are enabled for the RC path: `main` requires one review, CODEOWNERS review, stale review dismissal, conversation resolution, linear history, no force-push/delete, admin enforcement, and status contexts `test (20)` and `test (22)`. Dependabot vulnerability alerts/security updates, secret scanning, push protection, and private vulnerability reporting are enabled. The local npm identity check currently reports `npm whoami` as `pyyush`. + +## Install Source + +Use one of these install commands in the clean validator project: + +```bash +npm install @pyyush/useid@rc +npm install @pyyush/useid@1.0.0-rc.1 +``` + +## 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 or tag: `v1.0.0-rc.1` +- 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 + +- 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. diff --git a/README.md b/README.md index 4b26a8e..10dbff8 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,19 @@ npm version CI License - Node >= 20 + Node 20 and 22

-**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,18 +41,37 @@ 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 npm install @pyyush/useid ``` -Zero config. One dependency (zod). Works with any Node.js 20+ project. +Zero config. One dependency (zod). The `1.0.0-rc.1` package is tested and supported on Node.js 20 and 22, matching CI and release verification. + +## Release Status + +The npm `latest` dist-tag is verified as `0.1.0` and the npm `rc` dist-tag is verified as `1.0.0-rc.1` as of May 5, 2026. Install the release candidate with `npm install @pyyush/useid@rc`. The examples below describe the `1.0.0-rc.1` API and intended stable contract unless the changelog says otherwise. + +For a first working check in under five minutes with the RC: 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 RC + +If you are moving from the published `0.1.0` package toward the `1.0.0-rc.1` 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,60 @@ 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 (`1.0.0-rc.1`) | | Supported | Behavior | |-|-----------|----------| +| Node.js runtime | 20 and 22 | Tested CI/release matrix; package engines intentionally do not claim Node 24+ until extraction budget evidence is added | | 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 +- the runtime falls outside the Node 20/22 tested support matrix for this RC ## Full API @@ -167,7 +297,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..ba66a6e --- /dev/null +++ b/RELEASE_PLAN.md @@ -0,0 +1,671 @@ +# 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 after the release-hardening checkpoint was `0.2.0`; the current release branch is aligned at `1.0.0-rc.1`. +- 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`. + +The first RC is published as `1.0.0-rc.1` under the npm `rc` dist-tag. This plan still targets the final stable major release. + +RC version policy: + +- The first RC bump set `package.json` and `package-lock.json` together to `1.0.0-rc.1`, committed that change, and tagged the same commit as `v1.0.0-rc.1`. +- Later RCs use `1.0.0-rc.2`, `1.0.0-rc.3`, and so on. +- The release workflow rejects any tag/package mismatch, accepts only stable `x.y.z` or RC `x.y.z-rc.N` versions, publishes RCs under the npm `rc` dist-tag, and marks RC GitHub releases as prereleases. +- Final stable release uses `1.0.0`, tag `v1.0.0`, npm dist-tag `latest`, and a non-prerelease GitHub release. + +Remote release-owner evidence: + +- Remote GitHub settings for `pyyush/useid` are enabled: `main` requires one review, CODEOWNERS review, stale review dismissal, conversation resolution, linear history, no force-push/delete, admin enforcement, and status contexts `test (20)` and `test (22)`. +- Dependabot vulnerability alerts/security updates, secret scanning, push protection, and private vulnerability reporting are enabled. +- Local npm identity evidence: `npm whoami` reports `pyyush`. + +## 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. `@pyyush/useid@1.0.0-rc.1` has been published through the release workflow, but Task 10 is not complete because 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-05 after PR #3 remediation. +- 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. +- Public npm RC: registry reports `@pyyush/useid@1.0.0-rc.1` under the `rc` dist-tag. +- Local package: current release branch package metadata is `@pyyush/useid@1.0.0-rc.1`. +- PR #3 review remediation: release workflow policy checks now run in the `verify` job and again immediately before `npm publish`; the local policy script asserts both placements. +- PR #3 review remediation: packaged README/CHANGELOG describe `1.0.0-rc.1` as the current RC and narrow runtime support to the Node 20/22 tested matrix. +- Stable release target in this plan: `1.0.0`; it is not published. +- Version alignment policy: RC commits set `package.json` and `package-lock.json` to `1.0.0-rc.N`; the final release commit sets both to `1.0.0`. The release workflow requires the tag version to exactly match package metadata. +- RC naming policy: use npm SemVer `1.0.0-rc.N` and Git tag `v1.0.0-rc.N`. RC publishes use npm dist-tag `rc` and GitHub prereleases; stable publishes use npm dist-tag `latest` and normal GitHub releases. +- Confirmed remote settings: branch protection, required `test (20)`/`test (22)` contexts, CODEOWNERS review, Dependabot security, secret scanning/push protection, private vulnerability reporting, and npm identity `pyyush` are now recorded as enabled evidence rather than blockers. +- 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 follow-up RCs only after local and CI gates pass. +- Before tagging a follow-up RC, run `npm version 1.0.0-rc.N --no-git-tag-version`, review the `package.json` and `package-lock.json` version-only diff, commit it, and tag the exact commit as `v1.0.0-rc.N`. +- 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:** + +- 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`: passed on 2026-05-05 after PR #3 remediation; included release policy, build, typecheck, full Vitest, focused performance budgets, `npm audit --json`, and `npm pack --dry-run`. +- `npm test`: passed, 9 test files and 152 tests. +- `npm audit`: passed with 0 vulnerabilities. +- `npm pack --dry-run`: passed for `@pyyush/useid@1.0.0-rc.1`, package size 37.2 kB, unpacked size 157.1 kB, 8 files. +- Package internal-file assertion from the `release:verify` 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", "rc": "1.0.0-rc.1" }`. +- `npm view @pyyush/useid@1.0.0-rc.1 dist.tarball dist.integrity dist.shasum --json`: tarball `https://registry.npmjs.org/@pyyush/useid/-/useid-1.0.0-rc.1.tgz`, integrity `sha512-G8wvm6PIQlIH0rvhLJNC43pRiGfsKQHiTqyC73YKnhzzlKaZ0aA6ewZDeF73Asds1la7t9s4HgKinBwcfVhxuA==`, shasum `e35a3a16386110137f8e116435be9bf9858636c9`. +- Release workflow `v1.0.0-rc.1`: passed at `https://github.com/pyyush/useid/actions/runs/25339009937`. +- GitHub prerelease: `https://github.com/pyyush/useid/releases/tag/v1.0.0-rc.1`. +- Local package metadata inspection: `@pyyush/useid@1.0.0-rc.1`, `publishConfig.access` is `public`, registry is `https://registry.npmjs.org/`. +- Release policy check: local `release:verify` now asserts package/package-lock name/version alignment, release-workflow prerelease policy, and policy-script placement in both `verify` and immediately before `npm publish`. + +**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 originally blocked implementation because the worktree was dirty; the release-hardening checkpoint is now clean, so only scoped RC version-bump work should be added before tagging. +- 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 the published RC is `1.0.0-rc.1` and the stable target remains `1.0.0`. +- 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 || ^22.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 after PR #3 remediation. CI confirmation is still required for follow-up release changes. + +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: Passed for `1.0.0-rc.1`; blocked only for follow-up RCs if release-blocking feedback requires another candidate. + +Required evidence: + +- `1.0.0-rc.1` package reviewed from the release workflow artifact and npm registry metadata. +- 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 at least one external or external-like validator runs `1.0.0-rc.1`. + +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 provenance workflow and npm identity evidence recorded; actual publish evidence still required. +- 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: + +- External or external-like validation against `1.0.0-rc.1`. +- Follow-up RC only if release-blocking feedback requires one. +- Final publish/release evidence after the RC and external-user gates pass. + +## 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..2bb7868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "useid", - "version": "0.1.0", + "name": "@pyyush/useid", + "version": "1.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "useid", - "version": "0.1.0", + "name": "@pyyush/useid", + "version": "1.0.0-rc.1", "license": "Apache-2.0", "dependencies": { "zod": "^3.23.0" @@ -18,25 +18,25 @@ "vitest": "^4.0.0" }, "engines": { - "node": ">=20.0.0" + "node": "^20.0.0 || ^22.0.0" } }, "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..01ae7fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pyyush/useid", - "version": "0.1.0", + "version": "1.0.0-rc.1", "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": { @@ -32,8 +34,11 @@ "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", - "test": "vitest run", + "test": "vitest run --exclude src/__tests__/performance-budget.test.ts", + "test:perf": "vitest run --no-file-parallelism --maxWorkers=1 src/__tests__/performance-budget.test.ts", "test:watch": "vitest", + "release:verify": "node scripts/check-release-policy.mjs && npm run build && npm run typecheck && npm test && npm run test:perf && npm audit --json && npm pack --dry-run >/dev/null", + "prepublishOnly": "npm run build", "clean": "rm -rf dist" }, "dependencies": { @@ -62,6 +67,6 @@ "registry": "https://registry.npmjs.org/" }, "engines": { - "node": ">=20.0.0" + "node": "^20.0.0 || ^22.0.0" } } diff --git a/scripts/check-release-policy.mjs b/scripts/check-release-policy.mjs new file mode 100644 index 0000000..40761e6 --- /dev/null +++ b/scripts/check-release-policy.mjs @@ -0,0 +1,76 @@ +import { readFileSync } from "node:fs"; + +const packageJson = JSON.parse(readFileSync("package.json", "utf8")); +const packageLock = JSON.parse(readFileSync("package-lock.json", "utf8")); +const releaseWorkflow = readFileSync(".github/workflows/release.yml", "utf8"); + +const version = packageJson.version; +const rootLockPackage = packageLock.packages?.[""]; +const stableSemverPattern = /^\d+\.\d+\.\d+$/; +const rcSemverPattern = /^\d+\.\d+\.\d+-rc\.\d+$/; + +function fail(message) { + console.error(message); + process.exit(1); +} + +if (packageJson.name !== "@pyyush/useid") { + fail(`Expected package name @pyyush/useid, got ${packageJson.name}`); +} + +if (packageLock.name !== packageJson.name) { + fail( + `package-lock.json top-level name ${packageLock.name} does not match ${packageJson.name}`, + ); +} + +if (packageLock.version !== version) { + fail( + `package-lock.json top-level version ${packageLock.version} does not match ${version}`, + ); +} + +if (rootLockPackage?.name !== packageJson.name) { + fail( + `package-lock.json root package name ${rootLockPackage?.name} does not match ${packageJson.name}`, + ); +} + +if (rootLockPackage?.version !== version) { + fail( + `package-lock.json root package version ${rootLockPackage?.version} does not match ${version}`, + ); +} + +if (!stableSemverPattern.test(version) && !rcSemverPattern.test(version)) { + fail( + `Version ${version} must be stable x.y.z or RC x.y.z-rc.N before release verification`, + ); +} + +if (!releaseWorkflow.includes("npm publish --access public --provenance --tag")) { + fail("release.yml must publish with an explicit npm dist-tag"); +} + +if (!releaseWorkflow.includes("github_prerelease")) { + fail("release.yml must mark RC GitHub releases as prereleases"); +} + +const policyCheckCount = releaseWorkflow.match(/node scripts\/check-release-policy\.mjs/g) + ?.length ?? 0; + +if (policyCheckCount < 2) { + fail( + "release.yml must run scripts/check-release-policy.mjs in verify and immediately before npm publish", + ); +} + +if ( + !/node scripts\/check-release-policy\.mjs\s*\n\s*-\s*run:\s*npm publish --access public --provenance --tag/.test( + releaseWorkflow, + ) +) { + fail("release.yml must run scripts/check-release-policy.mjs immediately before npm publish"); +} + +console.log(`Release policy check passed for ${packageJson.name}@${version}.`); 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..c162cc0 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", @@ -221,6 +376,29 @@ describe("extractElements", () => { expect(button!.ancestorRoles.length).toBeLessThanOrEqual(3); }); + it("respects maxSiblingTokens option while preserving sibling order", () => { + const axTree = { + role: "WebArea", + name: "Page", + children: [ + { role: "button", name: "Save" }, + { role: "button", name: "Cancel" }, + { role: "button", name: "Reset" }, + { role: "button", name: "Delete" }, + ], + }; + + const elements = extractElements(makeDOMSnapshot(null), makeAXSnapshot(axTree), { + maxSiblingTokens: 2, + }); + + const save = elements.find((e) => e.accessibleName === "save"); + const cancel = elements.find((e) => e.accessibleName === "cancel"); + + expect(save!.siblingTokens).toEqual(["cancel", "reset"]); + expect(cancel!.siblingTokens).toEqual(["save", "reset"]); + }); + it("handles DOM snapshot with label association", () => { const domSnapshot = makeDOMSnapshot({ documents: [ 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..1545850 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, @@ -61,6 +66,11 @@ interface FlatAXElement { childIndex: number; } +interface NormalizedSiblingName { + node: AXNode; + name: string; +} + // ── Public API ────────────────────────────────────────────────────────────── export interface ExtractOptions { @@ -89,6 +99,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 +115,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 @@ -156,13 +167,10 @@ function flattenAccessibilityTree( node: AXNode, depth: number, ancestorRoles: string[], - siblings: AXNode[], + siblingNameIndex: NormalizedSiblingName[], childIndex: number ) { - const siblingNames = siblings - .filter((s) => s !== node && s.name) - .map((s) => normalizeAccessibleName(s.name)) - .slice(0, maxSiblings); + const siblingNames = collectSiblingNames(siblingNameIndex, node, maxSiblings); result.push({ role: node.role, @@ -176,8 +184,9 @@ function flattenAccessibilityTree( if (node.children) { const nextAncestors = [...ancestorRoles, normalizeRole(node.role)]; + const childSiblingNameIndex = precomputeSiblingNames(node.children); for (let i = 0; i < node.children.length; i++) { - walk(node.children[i]!, depth + 1, nextAncestors, node.children, i); + walk(node.children[i]!, depth + 1, nextAncestors, childSiblingNameIndex, i); } } } @@ -186,6 +195,36 @@ function flattenAccessibilityTree( return result; } +function precomputeSiblingNames(siblings: AXNode[]): NormalizedSiblingName[] { + const names: NormalizedSiblingName[] = []; + + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i]; + if (!sibling?.name) continue; + names.push({ node: sibling, name: normalizeAccessibleName(sibling.name) }); + } + + return names; +} + +function collectSiblingNames( + siblingNames: NormalizedSiblingName[], + node: AXNode, + maxSiblings: number +): string[] { + if (maxSiblings <= 0) return []; + + const names: string[] = []; + + for (const sibling of siblingNames) { + if (sibling.node === node) continue; + names.push(sibling.name); + if (names.length >= maxSiblings) break; + } + + return names; +} + // ── DOM snapshot parsing ──────────────────────────────────────────────────── interface DOMNodeInfo { @@ -196,6 +235,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 +253,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 +281,8 @@ function extractDOMStructure(snapshot: CDPDOMSnapshot | null): DOMNodeInfo[] { const bounds = layoutMap.get(i); // Check for associated label (simplified: look for