diff --git a/docs/repo/testing/has-comparison.md b/docs/repo/testing/has-comparison.md
deleted file mode 100644
index 6de6d87c..00000000
--- a/docs/repo/testing/has-comparison.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# WPT `:has()` comparison
-
-On September 17, 2026, the maintenance and v3 branches were checked against the same pinned WPT pages in Chrome for Testing 154.0.8037.0.
-
-| `nwsapi` branch | Revision | Passed | Failed | Total |
-| --- | --- | ---: | ---: | ---: |
-| `master` (v2) | `932caf983e1ac7267bec42886595fda58edacf1f` | 105 | 7 | 112 |
-| `prerelease/3.0.0` (v3) | `f1347c9ddb359c22255ebed511b6ee41335fa2ff` | 112 | 0 | 112 |
-
-The selection contains four DOM matching pages with 78 subtests and three parsing pages with 34 subtests. Both branches pass every subtest in the matching pages. The seven maintenance failures are in the parsing pages, including one direct matching test for nested `:has()` inside forgiving lists.
-
-## Maintenance failures
-
-| Case | Expected behavior | Maintenance result |
-| --- | --- | --- |
-| `.a:has(.b:has(.c))` | Reject nested `:has()` with `SyntaxError`. | Accepted. |
-| Nested `:has()` inside `:is()` or `:where()` | Discard the invalid nested branch. | `:has(:is(:has(*)))` incorrectly matches. The upstream subtest stops at this first failing assertion. |
-| `*\|*:has(*)` | Accept a wildcard namespace. | Throws `SyntaxError`. |
-| `:has(*\|*)` | Accept a wildcard namespace. | Throws `SyntaxError`. |
-| `:has()` | Reject an empty argument in every selector API. | An empty element's `querySelector()` accepts it. |
-| `:has(123)` | Reject an invalid selector argument. | Accepted. |
-| `:has(.a, 123)` | Reject an unforgiving list containing an invalid argument. | Accepted. |
-
-This comparison records existing behavior. It does not change either engine or classify these failures as expected passes.
-
-## Measurement scope
-
-The WPT revision is `fd983776a7cd19ebcda7a2bcb69c74330ee5d8c9`. Each page receives the engine before its scripts run. The comparison verifies replacement of all eight Document, Element, and DocumentFragment selector methods. Maintenance uses the unchanged bytes from `master:src/nwsapi.js`. v3 uses its built `dist/nwsapi.js`.
-
-The matching pages are `has-basic.html`, `has-relative-argument.html`, `has-argument-with-explicit-scope.html`, and `has-matches-to-uninserted-elements.html` under `css/selectors/`. The parsing pages are `parse-has.html`, `parse-has-disallow-nesting-has-inside-has.html`, and `parse-has-forgiving-selector.html` under `css/selectors/parsing/`.
-
-Parsing pages use the repository's existing selector-validity helper, which checks the installed DOM APIs. CSSOM serialization is outside this measurement. The nested-selector page also retains its direct `matches()` assertions. Rendering, stylesheet invalidation, screenshot, and crash-only tests are excluded. No subtests or failure results are filtered within a selected page.
-
-The filename selection also finds `parse-has-slotted.tentative.html`. This tests the separate `:has-slotted` pseudo-class and is excluded from the table above. Maintenance passes 4 of its 23 subtests. v3 passes all 23.
-
-The ordinary v3 runner passed all eight pages with this command:
-
-```sh
-pnpm run test:wpt --grep '/css/selectors/(has-|parsing/parse-has)' --reporter=dot,json
-```
-
-The standalone branch comparison reproduced the ordinary runner's v3 counts, engine hash, WPT revision, browser version, and harness outcomes for every page. All page harnesses completed successfully on both branches. The [recorded results](../../../assets/repo/bench/wpt-has-comparison.json) contain the full subtest names, statuses, failure messages, and engine hashes. The table totals are sums of pages whose `group` is `has`.
diff --git a/test/repo/unit/has-validation.test.mts b/test/repo/unit/has-validation.test.mts
new file mode 100644
index 00000000..afbfccc1
--- /dev/null
+++ b/test/repo/unit/has-validation.test.mts
@@ -0,0 +1,165 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { JSDOM } from 'jsdom'
+import { test, type TestContext } from 'vitest'
+
+const source = readFileSync(
+ new URL('../../../dist/nwsapi.js', import.meta.url),
+ 'utf8',
+)
+
+function fixture(t: TestContext) {
+ const { window } = new JSDOM(
+ '
',
+ {
+ runScripts: 'outside-only',
+ },
+ )
+ t.onTestFinished(() => window.close())
+ window.eval(source)
+ const engine = window.NW.Dom
+ engine.install()
+ return { window, document: window.document, engine }
+}
+
+for (const selector of [
+ ':has()',
+ ':has(123)',
+ ':has(span, 123)',
+ ':has(123, span)',
+ ':has(, span)',
+ ':has(span,)',
+ ':has(:has(*))',
+ ':has(span:has(*))',
+ ':has(:not(:has(*)))',
+ ':not(:has(123))',
+ ':has(::before)',
+ ':has(:before)',
+ ':has(:unknown)',
+]) {
+ test(`${selector} throws before visiting candidates in every selector API`, t => {
+ const { window, document, engine } = fixture(t)
+ const empty = document.createElement('section')
+ const parent = document.getElementById('parent')!
+ const contexts = [
+ document,
+ document.implementation.createHTMLDocument(''),
+ parent,
+ empty,
+ document.createDocumentFragment(),
+ ]
+ for (let repeat = 0; repeat < 2; repeat += 1) {
+ for (const context of contexts) {
+ for (const query of [
+ () => context.querySelector(selector),
+ () => context.querySelectorAll(selector),
+ () => engine.select(selector, context),
+ () => engine.first(selector, context),
+ ]) {
+ assert.throws(
+ query,
+ error =>
+ error instanceof window.DOMException &&
+ error.name === 'SyntaxError',
+ )
+ }
+ }
+ for (const element of [parent, empty]) {
+ assert.throws(() => element.matches(selector), { name: 'SyntaxError' })
+ assert.throws(() => element.closest(selector), { name: 'SyntaxError' })
+ assert.throws(() => engine.match(selector, element), {
+ name: 'SyntaxError',
+ })
+ }
+ }
+ assert.equal(parent.matches(':has(> span)'), true)
+ })
+}
+
+for (const pseudo of ['is', 'where']) {
+ test(`:has() discards nested branches only inside forgiving :${pseudo}()`, t => {
+ const { document, engine } = fixture(t)
+ const parent = document.getElementById('parent')!
+ for (const invalid of [':has(*)', ':not(:has(*))', '::before']) {
+ for (let repeat = 0; repeat < 2; repeat += 1) {
+ assert.equal(parent.matches(`:has(:${pseudo}(${invalid}))`), false)
+ const selector = `div:has(:${pseudo}(${invalid}, span))`
+ assert.equal(parent.matches(selector), true)
+ assert.deepEqual(Array.from(document.querySelectorAll(selector)), [
+ parent,
+ ])
+ }
+ }
+ assert.equal(
+ parent.matches(`:has(:${pseudo}(:where(:has(*)), span))`),
+ true,
+ )
+ const selector = `div:has(:${pseudo}(:has(*), span))`
+ engine.configure({ FORGIVING: false })
+ assert.throws(() => document.createElement('div').querySelector(selector), {
+ name: 'SyntaxError',
+ })
+ assert.throws(() => parent.matches(selector), { name: 'SyntaxError' })
+ engine.configure({ FORGIVING: true })
+ assert.equal(parent.matches(selector), true)
+ })
+}
+
+test('wildcard namespaces work on either side of :has()', t => {
+ const { document } = fixture(t)
+ const parent = document.getElementById('parent')!
+ for (const selector of [
+ '*|*:has(> span)',
+ 'div:has(*|*)',
+ 'div:has(> *|span)',
+ '*|div:has(> *|*)',
+ ]) {
+ for (let repeat = 0; repeat < 2; repeat += 1) {
+ assert.deepEqual(Array.from(document.querySelectorAll(selector)), [
+ parent,
+ ])
+ assert.equal(parent.matches(selector), true)
+ }
+ }
+})
+
+test('quoted and escaped pseudo text stays literal inside :has()', t => {
+ const { document } = fixture(t)
+ const parent = document.getElementById('parent')!
+ const child = document.getElementById('child')!
+ child.setAttribute('data-value', ':has(*), ::before')
+ child.className = ':has(*)'
+ for (const selector of [
+ 'div:has(> [data-value=":has(*), ::before"])',
+ "div:has(> [data-value=':has(*), ::before'])",
+ String.raw`div:has(> .\:has\(\*\))`,
+ 'div:has(> :is(:has(*), [data-value=":has(*), ::before"]))',
+ ]) {
+ assert.deepEqual(Array.from(document.querySelectorAll(selector)), [parent])
+ assert.equal(parent.matches(selector), true)
+ }
+})
+
+test('quiet validation rejects the entire argument list and preserves verbose retries', t => {
+ const { document, engine } = fixture(t)
+ const parent = document.getElementById('parent')!
+ for (const selector of [
+ 'div:has()',
+ 'div:has(span, 123)',
+ 'div:has(123, span)',
+ 'div:has(:has(*), span)',
+ 'div:not(:has(123))',
+ ]) {
+ for (let repeat = 0; repeat < 2; repeat += 1) {
+ engine.configure({ VERBOSITY: false, LOGERRORS: false })
+ assert.deepEqual(Array.from(engine.select(selector)), [])
+ assert.equal(engine.match(selector, parent), false)
+ engine.configure({ VERBOSITY: true })
+ assert.throws(() => engine.select(selector), { name: 'SyntaxError' })
+ assert.throws(() => engine.match(selector, parent), {
+ name: 'SyntaxError',
+ })
+ }
+ }
+ assert.equal(parent.matches(':has(> span)'), true)
+})