From 8347babde8c03bbb5ba038754f0d28f763c38ec9 Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Wed, 15 Jul 2026 13:35:57 +0200
Subject: [PATCH 1/7] Add Common Crawl HTML API fuzz harness
---
.gitignore | 1 +
tools/html-api-fuzz/README.md | 563 ++++++
.../codex-triage-orchestrator.php | 935 ++++++++++
tools/html-api-fuzz/commoncrawl-analysis.php | 14 +
tools/html-api-fuzz/launcher.php | 359 ++++
tools/html-api-fuzz/lib/CommonCrawlRunner.php | 662 +++++++
tools/html-api-fuzz/lib/Corpus.php | 78 +
tools/html-api-fuzz/lib/Generator.php | 1409 ++++++++++++++
tools/html-api-fuzz/lib/HtmlApiBootstrap.php | 52 +
tools/html-api-fuzz/lib/Mutator.php | 111 ++
tools/html-api-fuzz/lib/OracleFinding.php | 273 +++
tools/html-api-fuzz/lib/OracleRenderer.php | 324 ++++
tools/html-api-fuzz/lib/Prng.php | 56 +
tools/html-api-fuzz/lib/ResultStore.php | 406 ++++
tools/html-api-fuzz/lib/Signature.php | 142 ++
tools/html-api-fuzz/lib/Support.php | 508 +++++
tools/html-api-fuzz/lib/TagInvariants.php | 437 +++++
tools/html-api-fuzz/lib/TreeRenderer.php | 1346 +++++++++++++
tools/html-api-fuzz/lib/Worker.php | 775 ++++++++
tools/html-api-fuzz/lib/autoload.php | 15 +
tools/html-api-fuzz/lib/wp-stubs.php | 52 +
tools/html-api-fuzz/minimize.php | 494 +++++
tools/html-api-fuzz/oracles/lexbor/README.md | 74 +
tools/html-api-fuzz/oracles/lexbor/build.sh | 89 +
.../oracles/lexbor/lexbor-tree-oracle.c | 1296 +++++++++++++
tools/html-api-fuzz/replay.php | 148 ++
tools/html-api-fuzz/runner.php | 511 +++++
.../start-continuous-run-tmux.sh | 166 ++
tools/html-api-fuzz/stop.php | 390 ++++
.../tests/codex-triage-orchestrator-smoke.php | 179 ++
.../tests/commoncrawl-analysis-smoke.php | 139 ++
.../fixtures/commoncrawl-timeout-worker.php | 6 +
.../tests/generator-policy-smoke.php | 1658 +++++++++++++++++
.../tests/lexbor-oracle-smoke.php | 184 ++
.../tests/result-store-smoke.php | 234 +++
.../tests/runner-retention-smoke.php | 232 +++
tools/html-api-fuzz/tests/stop-smoke.php | 531 ++++++
.../tree-renderer-normalization-smoke.php | 421 +++++
.../tests/watcher-sqlite-smoke.php | 206 ++
tools/html-api-fuzz/watcher.php | 620 ++++++
tools/html-api-fuzz/worker.php | 103 +
41 files changed, 16199 insertions(+)
create mode 100644 tools/html-api-fuzz/README.md
create mode 100755 tools/html-api-fuzz/codex-triage-orchestrator.php
create mode 100644 tools/html-api-fuzz/commoncrawl-analysis.php
create mode 100755 tools/html-api-fuzz/launcher.php
create mode 100644 tools/html-api-fuzz/lib/CommonCrawlRunner.php
create mode 100644 tools/html-api-fuzz/lib/Corpus.php
create mode 100644 tools/html-api-fuzz/lib/Generator.php
create mode 100644 tools/html-api-fuzz/lib/HtmlApiBootstrap.php
create mode 100644 tools/html-api-fuzz/lib/Mutator.php
create mode 100644 tools/html-api-fuzz/lib/OracleFinding.php
create mode 100644 tools/html-api-fuzz/lib/OracleRenderer.php
create mode 100644 tools/html-api-fuzz/lib/Prng.php
create mode 100644 tools/html-api-fuzz/lib/ResultStore.php
create mode 100644 tools/html-api-fuzz/lib/Signature.php
create mode 100644 tools/html-api-fuzz/lib/Support.php
create mode 100644 tools/html-api-fuzz/lib/TagInvariants.php
create mode 100644 tools/html-api-fuzz/lib/TreeRenderer.php
create mode 100644 tools/html-api-fuzz/lib/Worker.php
create mode 100644 tools/html-api-fuzz/lib/autoload.php
create mode 100644 tools/html-api-fuzz/lib/wp-stubs.php
create mode 100755 tools/html-api-fuzz/minimize.php
create mode 100644 tools/html-api-fuzz/oracles/lexbor/README.md
create mode 100755 tools/html-api-fuzz/oracles/lexbor/build.sh
create mode 100644 tools/html-api-fuzz/oracles/lexbor/lexbor-tree-oracle.c
create mode 100755 tools/html-api-fuzz/replay.php
create mode 100755 tools/html-api-fuzz/runner.php
create mode 100755 tools/html-api-fuzz/start-continuous-run-tmux.sh
create mode 100644 tools/html-api-fuzz/stop.php
create mode 100755 tools/html-api-fuzz/tests/codex-triage-orchestrator-smoke.php
create mode 100755 tools/html-api-fuzz/tests/commoncrawl-analysis-smoke.php
create mode 100755 tools/html-api-fuzz/tests/fixtures/commoncrawl-timeout-worker.php
create mode 100644 tools/html-api-fuzz/tests/generator-policy-smoke.php
create mode 100755 tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
create mode 100644 tools/html-api-fuzz/tests/result-store-smoke.php
create mode 100644 tools/html-api-fuzz/tests/runner-retention-smoke.php
create mode 100644 tools/html-api-fuzz/tests/stop-smoke.php
create mode 100644 tools/html-api-fuzz/tests/tree-renderer-normalization-smoke.php
create mode 100644 tools/html-api-fuzz/tests/watcher-sqlite-smoke.php
create mode 100755 tools/html-api-fuzz/watcher.php
create mode 100755 tools/html-api-fuzz/worker.php
diff --git a/.gitignore b/.gitignore
index 15876fa47fee8..dbec517727bbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@ wp-tests-config.php
/packagehash.txt
/.gutenberg-hash
/artifacts
+/tools/html-api-fuzz/oracles/lexbor/build
/setup.log
/coverage
diff --git a/tools/html-api-fuzz/README.md b/tools/html-api-fuzz/README.md
new file mode 100644
index 0000000000000..f192a4c7aa1c5
--- /dev/null
+++ b/tools/html-api-fuzz/README.md
@@ -0,0 +1,563 @@
+# HTML API Fuzzer
+
+Pure PHP fuzzer for the WordPress HTML API under `src/wp-includes/html-api`.
+It compares `WP_HTML_Processor` against a selectable tree-construction oracle
+using an html5lib-style textual tree, and separately checks a set of API
+invariants described under “Invariants” below. The default oracle is PHP's
+`Dom\HTMLDocument`, preserving the historical behavior.
+
+No browser, Playwright, Node, or `wp-env` is involved.
+
+## Requirements
+
+- PHP 8.4+ with ext-dom, for `Dom\HTMLDocument`.
+- Run from the repository root.
+- Optional source-built Lexbor oracle: `git`, `cmake`, and a C compiler.
+
+## Common Crawl with cc-analyzer
+
+`commoncrawl-analysis.php` is an analysis callback for `cc-analyzer.phar`. It
+accepts the analyzer's `CcAnalyzer\Analysis\HtmlAnalysisInput` value object,
+runs the raw response body through `WP_HTML_Processor` in full-document mode,
+compares the resulting tree with Lexbor, and records the Common Crawl
+provenance needed to locate or replay the document.
+
+Build the oracle from the current upstream `master` first:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+The build resolves the moving ref, uses commit-keyed build/install directories,
+refuses a dirty Lexbor checkout, and writes `build/build-manifest.json` with the
+requested ref, resolved commit, upstream URL, build time, compiler, CMake
+version, and oracle SHA-256. For exact reproduction, rebuild the manifest's
+`resolvedCommit`, for example:
+
+```sh
+LEXBOR_COMMIT=de1d07a7765aad37090cc36f7fac3bb59e21467d \
+ tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+Then point cc-analyzer's HTML analysis-file argument at the absolute path to:
+
+```text
+tools/html-api-fuzz/commoncrawl-analysis.php
+```
+
+The PHAR owns crawl selection, ranges, checkpoints, and concurrency. The
+callback boundary is the same as other cc-analyzer HTML analyses: the file
+returns a `static function (HtmlAnalysisInput $document): void` closure. Use
+`php /path/to/cc-analyzer.phar --help` for the binary's crawl-specific command
+and argument names. This checkout does not contain `cc-analyzer.phar`, so the
+real PHAR CLI boundary is deliberately not claimed as tested; the included
+smoke test exercises a value object matching the observed callback contract.
+
+Each accepted document runs in a separate PHP child with its own memory and
+wall-clock limit. The callback writes `input.bin` and an initial replay before
+starting that child. A timeout, memory exhaustion, crash, or signal is retained
+as a finding without terminating cc-analyzer or losing the triggering bytes.
+
+The Common Crawl adapter uses Lexbor by default. Its environment is:
+
+- `CC_ANALYZER_OUTPUT_DIR`: shared output directory for this analyzer run. If
+ absent, a unique directory is created under `artifacts/html-api-commoncrawl`.
+- `HTML_API_CC_RUN_ID`: explicit immutable run identifier. When the analyzer
+ provides an output directory, the default is a stable hash of that path.
+- `HTML_API_FUZZ_LEXBOR_ORACLE`: non-default Lexbor oracle binary path.
+- `HTML_API_CC_EXPECT_LEXBOR_COMMIT`: optional exact commit assertion; startup
+ fails if the executable reports another commit.
+- `HTML_API_CC_ORACLE_TIMEOUT_MS`: Lexbor subprocess timeout; default `10000`.
+- `HTML_API_CC_PROCESS_TIMEOUT_MS`: whole-document worker timeout; default
+ `30000`.
+- `HTML_API_CC_MEMORY_LIMIT`: PHP memory limit for each worker; default `256M`.
+- `HTML_API_CC_MAX_INPUT_BYTES`: largest response body to analyze; default
+ `2097152`, or `0` for unlimited.
+- `HTML_API_CC_MAX_TOKENS`, `HTML_API_CC_MAX_NODES`,
+ `HTML_API_CC_MAX_DEPTH`, and `HTML_API_CC_MAX_TREE_BYTES`: bounded work and
+ tree-output ceilings; defaults are `50000`, `50000`, `512`, and `16777216`.
+- `HTML_API_CC_CHECKS`: `baseline`, `full`, or `sampled` (default). Baseline
+ performs the WordPress/Lexbor differential; full also runs API invariants,
+ mutation differential, and normalize preservation.
+- `HTML_API_CC_FULL_SAMPLE_PERCENT`: deterministic full-check share when using
+ `sampled`; default `1`.
+- `HTML_API_CC_REQUIRE_UTF8`: defaults to `1`. Invalid UTF-8 bodies are
+ summarized as skipped because the WordPress HTML API consumes UTF-8 and a
+ legacy-encoded response otherwise produces mostly encoding noise. Set it to
+ `0` for raw-byte/encoding fuzzing.
+- `HTML_API_CC_MAX_KEEP_PER_SIGNATURE`: retained examples per distinct
+ signature; default `3`.
+- `HTML_API_CC_RETAIN_ALL`: retain passing inputs too; default `0`.
+- `HTML_API_CC_ORACLE=php-dom`: testing/debug escape hatch. Normal Common
+ Crawl runs should leave this unset so the independent source-built Lexbor
+ oracle is used.
+- `CC_ANALYZER_VERSION`, `CC_ANALYZER_CRAWL`, and `CC_ANALYZER_INVOCATION`:
+ optional provenance strings recorded verbatim in the immutable run config.
+
+The output root contains:
+
+- `configuration.json`: run ID and exact configuration fingerprint plus Git,
+ PHP, Lexbor build manifest/hash, limits, and analyzer provenance. Reusing the
+ directory with different settings fails at startup.
+- `commoncrawl-summary.ndjson`: one locked, append-only record per document;
+ safe for concurrent analyzer workers.
+- `coverage.json`: aggregate total, covered, failed, and per-status counts.
+- `findings/SIGNATURE/DOCUMENT/`: bounded byte-exact exemplars containing
+ `input.bin`, `result.json`, `replay.json`, both rendered trees, and a
+ `.complete` marker. Findings are written in `pending/` and atomically renamed
+ only after every checked write succeeds; retries of the same record/body are
+ idempotent. Any directory left in `pending/` is recoverable evidence of a
+ callback interruption.
+
+Passing inputs are represented only by their summary by default. A finding's
+replay embeds the exact input body, so it remains reproducible without another
+Common Crawl download:
+
+```sh
+php tools/html-api-fuzz/replay.php \
+ --replay artifacts/html-api-commoncrawl/findings/SIGNATURE/DOCUMENT/replay.json
+```
+
+Transport charset, content type, target URI, WARC record ID, analyzer state
+key, and source range are metadata only. The comparison intentionally feeds
+the same raw bytes to both parsers; it never silently transcodes one side.
+
+## Common Commands
+
+Run one generated seed:
+
+```sh
+php tools/html-api-fuzz/worker.php --seed 1 --output-dir artifacts/html-api-fuzz/seed-1
+```
+
+Run one generated seed with a specific terminal payload policy:
+
+```sh
+php tools/html-api-fuzz/worker.php --seed 1 --payload-policy valid-utf8 --output-dir artifacts/html-api-fuzz/seed-1
+```
+
+Run a batch in worker subprocesses (seeds are batched into shared worker
+processes, 25 per process by default; see `--batch-size`):
+
+```sh
+php tools/html-api-fuzz/runner.php --max-seeds 100 --duration-seconds 60
+```
+
+Run a structural UTF-8-biased batch with a post-generation byte cap:
+
+```sh
+php tools/html-api-fuzz/runner.php --max-seeds 100 --payload-policy valid-utf8 --max-input-bytes 4096
+```
+
+Build and run against the source-built Lexbor oracle:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build.sh
+php tools/html-api-fuzz/worker.php --seed 1 --dom-oracle lexbor-source --output-dir artifacts/html-api-fuzz/seed-1-lexbor
+php tools/html-api-fuzz/runner.php --max-seeds 100 --dom-oracle lexbor-source --duration-seconds 60
+```
+
+Use `--lexbor-oracle-bin PATH` or `HTML_API_FUZZ_LEXBOR_ORACLE` when the
+oracle binary is not at
+`tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle`.
+
+Run indefinitely:
+
+```sh
+php tools/html-api-fuzz/runner.php --duration-seconds 0 --max-seeds 0
+```
+
+Run parallel lanes and triage failures after completion:
+
+```sh
+php tools/html-api-fuzz/launcher.php --lanes 4 --max-seeds 1000 --watcher
+```
+
+For continuous fuzzing, run the launcher with `--duration-seconds 0 --max-seeds 0`
+and run `watcher.php` in a second shell against the same output directory.
+
+Stop an indefinite run gracefully (each lane finishes its current batch, the
+watcher performs a final scan, and the codex orchestrator drains its running
+jobs):
+
+```sh
+php tools/html-api-fuzz/stop.php --run-dir artifacts/html-api-fuzz/run-...
+```
+
+Without `--run-dir` the most recently active *unfinished* run under
+`artifacts/html-api-fuzz` is targeted. Finished and stale runs are not
+preferred; if nothing live is found, the most recent stopped-looking run is
+targeted with a warning. The script creates the stop file advertised by the
+run state and also `RUN_DIR/STOP` when a run directory is known, so watchers
+and orchestrators see the stop request. For a standalone runner with custom
+`--stop-file PATH`, both files are written. Relative custom stop files are
+resolved with the runner cwd recorded in new runner state; for older state,
+pass only `--stop-file PATH` to write a known stop file directly if needed.
+With `--run-dir --stop-file PATH`, the explicit path is added to the run-state
+and `RUN_DIR/STOP` targets. `touch` works just as well. The launcher and runner refuse to start while a stop file already
+exists — remove `STOP` (or the custom stop file) before reusing a run
+directory. (A stop requested in the sub-second window between the launcher's
+startup check and a lane's own makes that lane refuse rather than stop
+gracefully; the run still ends.)
+If state cannot be read or old state lacks enough context to locate a relative
+custom stop file, the tool still writes `RUN_DIR/STOP` but exits `2` with
+`ok: false` and warnings because a standalone custom stop file may be unknown.
+
+The watcher exits after a final scan once every runner under the run
+directory reports a stop reason. A runner whose state has gone silent is
+presumed dead after `--stop-stale-seconds` (default 120); per lane that
+threshold is floored at twice the lane's advertised batch budget
+(`timeout-ms × batch-size`), so long batches are not mistaken for crashes.
+The stop tool uses the same default stale threshold when auto-selecting the
+latest unfinished run.
+
+Replay a failure from a retained seed directory, or from the lane's SQLite
+store when the seed directory was pruned (see "Artifact Retention"):
+
+```sh
+php tools/html-api-fuzz/replay.php --replay artifacts/html-api-fuzz/run-.../seed-.../primary/replay.json
+php tools/html-api-fuzz/replay.php --store artifacts/html-api-fuzz/run-.../lane-00/results.sqlite --seed 12345
+```
+
+Minimize a failure while preserving the same signature:
+
+```sh
+php tools/html-api-fuzz/minimize.php --replay artifacts/html-api-fuzz/run-.../seed-.../primary/replay.json
+```
+
+By default (`--probe-mode auto`) the minimizer evaluates candidates in worker
+subprocesses so `--timeout-ms` can kill pathological candidates and each probe
+starts with fresh PHP state. Use `--probe-mode in-process` for faster
+exact-signature minimization when that isolation is not needed; in-process
+probes write only the final minimized artifacts unless
+`--keep-candidate-artifacts` is also passed. Use `--probe-mode process` to
+force subprocess probes explicitly.
+
+Watch an existing run directory and minimize new distinct signatures:
+
+```sh
+php tools/html-api-fuzz/watcher.php --run-dir artifacts/html-api-fuzz/run-... --once
+```
+
+Configured ceilings are reported as `failureClass: "resource-limit"` and remain
+in the watcher/minimizer triage path. This bucket includes tag/tree token
+ceilings (`tag-token-limit-exceeded`, `mutation-token-limit-exceeded`,
+`wordpress-token-limit-exceeded`) and oracle node ceilings
+(`node-limit-exceeded`, recorded as `dom-node-limit-exceeded` in historical
+signature facts). Process timeouts, PHP fatal errors, and memory failures are
+separate failures and are also in scope for triage.
+
+## Execution Model
+
+The runner batches consecutive seeds into one worker process
+(`worker.php --batch-count N`, default `--batch-size 25` on the runner) so the
+WordPress bootstrap and process spawn are paid once per batch rather than once
+per seed. Each seed still writes its own `seed-N/primary` artifacts. If a batch
+process dies or times out mid-way, seeds left without a `result.json` are
+re-run individually in isolation, so a crash on one input cannot take
+neighboring seeds' results with it.
+
+## Input Stages
+
+Seeds are deterministically split between two input stages:
+
+- **Generated** (default ~80%): the structural grammar described under
+ “Generator Profiles”.
+- **Corpus-mutated** (default ~20%, `--corpus-mutate-percent N` on
+ `worker.php`/`runner.php`): a `#data` section from the html5lib-tests
+ tree-construction corpus (`tests/phpunit/data/html5lib-tests`), passed
+ through 1–4 deterministic mutations (byte insert/replace, chunk
+ delete/duplicate, tag-name swap, case toggle, corpus splice). The stage,
+ corpus file, entry index, and operations are recorded in result metadata,
+ and the mutated input itself is in the replay manifest, so replays are
+ standalone. Inputs report `inputSource: "corpus-mutated"` and
+ `profile: "corpus-mutated"`.
+
+Both stages derive entirely from the seed, so seed N always produces the same
+input for the same fuzzer version and corpus.
+
+## Artifact Layout
+
+The runner writes:
+
+- `results.sqlite`: one row per attempted seed (table `attempts`, WAL mode).
+ Passing attempts store summary columns only — every attempt is regenerable
+ from its seed. Failure rows additionally store the summary, result, and
+ replay JSON documents; the replay embeds the input as base64, so a pruned
+ failure can be reproduced with `replay.php --store results.sqlite --seed N`.
+ `signature_hash` and `family_key` are indexed columns for grouping
+ failures without `json_extract`. `oracle_kind`, `oracle_version`,
+ `oracle_commit`, and `oracle_binary` record which oracle generated the
+ summary, including for passing rows whose JSON payloads are pruned. The
+ watcher tails these stores
+ incrementally by row id. (`summary.ndjson` files from older runs are still
+ scanned.) Durability is `synchronous=NORMAL`: an OS crash (not a process
+ crash) can lose the last moments of a run.
+- `events.ndjson`: runner lifecycle events, including batch boundaries.
+- `logs/batch-N.log`: output of a batch worker process, kept only when the
+ batch contained a retained failure or a seed that needed an isolated
+ re-run — over-cap repeats of a known signature do not accumulate batch
+ logs.
+- `state.json`: aggregate counters, stop reason, and compact Git metadata.
+ Oracle losses are counted per class: `oracleParseErrors` (inputs the
+ selected oracle rejects receive no differential coverage),
+ `oracleUnsupported` (tree shapes the oracle cannot represent), and
+ `oracleTolerated`
+ (comparisons that passed only under the documented scalar tolerance).
+- `seed-N/primary/input.bin`: raw generated bytes.
+- `seed-N/primary/replay.json`: base64 replay manifest, including the commit
+ hash, tracked-file dirty state, selected oracle, and fragment context needed
+ to interpret a standalone replay.
+- `seed-N/primary/result.json`: full worker result.
+- `seed-N/primary/wordpress-tree.txt` and `dom-tree.txt`: rendered trees when available.
+
+### Artifact Retention
+
+Seed directories are working space, not the archive. After each seed is
+recorded in `results.sqlite`, its `seed-N` directory is deleted unless the
+attempt failed *and* the failure's signature has fewer than
+`--max-keep-per-signature` (default 5, minimum 1) exemplar directories still
+on disk in this lane. The cap counts directories, not rows, so restarting a
+runner against the same output directory neither double-counts re-recorded
+seeds nor deletes a previously retained exemplar. The first exemplar of every
+new signature is always retained, so the watcher's minimization path keeps a
+replay file to work from; subsequent repeats of a known signature add a
+database row and nothing else. A failure whose replay document is missing
+(worker killed before writing it) always keeps its directory — the files are
+the only reproduction. Disk growth is therefore proportional to *new
+distinct failures*, not to seeds executed.
+
+The cap applies per lane: the launcher passes the same value to every lane,
+so a signature that appears in all lanes keeps up to `N × lanes` directories
+across the run. Use `--keep-all-artifacts` (runner or launcher) to keep every
+seed directory for debugging.
+
+The run-level Git metadata intentionally stays compact: full and short commit
+hash, current branch when available, commit date, and a dirty flag for
+tracked-file changes. The dirty flag is tri-state: `true`, `false`, or `null`
+when Git is unavailable or dirty detection fails. Full `git status` or diff
+output is not recorded because it is noisy, can expose local edits, and grows
+indefinitely in long runs. Launcher and runner processes collect this metadata
+once and pass it to workers so long runs do not invoke Git for every seed.
+Replayed and minimized manifests keep current checkout metadata at the top level
+and preserve discovery provenance in `sourceReplay`.
+
+The watcher writes triage state under `.triage-watcher` by default, or under
+`--state-dir` when provided. Each signature gets a stable directory containing
+`failure.json`, minimizer logs, and minimized replay/result artifacts. Failed
+minimizations are retried on later scans, up to `--max-minimize-retries`
+(default 3) attempts per signature.
+
+## Modes and Fragment Contexts
+
+- `fragment-body`: parse as a fragment. The selected oracle uses real fragment
+ parsing (the `innerHTML` setter on a context element of an empty document),
+ not a document-wrapping approximation.
+- `full-document`: parse as a full HTML document.
+- `auto`: weighted choice.
+
+In fragment mode a context element is selected per seed
+(`--fragment-context TAG` on `worker.php` for replays). `` dominates;
+the other contexts (`div`, `p`, `td`, `tr`, `table`, `caption`, `colgroup`,
+`select`, `option`, `template`, `title`, `textarea`, `script`, `style`,
+`svg`, `math`) receive a small probe weight. `WP_HTML_Processor::create_fragment()`
+currently supports only ``, so non-body contexts are recorded as
+`status: "unsupported"` today; when create_fragment() gains context support
+the fuzzer picks up the new coverage with no changes. The selected oracle already
+parses every context correctly.
+
+Unsupported `WP_HTML_Processor` cases are expected by default and are recorded
+as successful attempts with `status: "unsupported"`. Use `--fail-unsupported`
+when you want unsupported cases to become failures.
+
+## Invariants
+
+Each seed checks, in order, stopping at the first failing class:
+
+1. **Tag Processor invariants** (`tag-invariant-failed`): token loop
+ termination under the token ceiling; non-null token type/name/tag;
+ attribute getters and `class_list()` iteration do not throw;
+ `get_updated_html()` with no queued edits returns the input unchanged; a
+ simple `set_attribute()` mutation is visible to a re-scan; and
+ **seek consistency** — a bookmark set at a seed-chosen token, after
+ scanning to the end and seeking back, must reproduce the identical token
+ stream (`seek-token-stream-mismatch`).
+2. **Differential tree comparison** (`tree-mismatch` / `encoding-mismatch`):
+ the WordPress tree must equal the selected oracle tree (see “Tree Comparison”).
+3. **Breadcrumb consistency** (`breadcrumb-mismatch`): at every tag token,
+ `get_breadcrumbs()` must agree with the element stack derived from token
+ order and `expects_closer()`.
+4. **Mutation differential** (`mutation-tree-mismatch` /
+ `mutation-delta-mismatch`), only on a clean baseline: after setting
+ `data-fuzz="1"` on the first tag, the mutated document must parse
+ identically in WordPress and the selected oracle, and the WordPress tree must
+ change by exactly the one attribute line (unless formatting-element
+ reconstruction clones the attribute, or tree construction legitimately
+ drops the mutated element, in which case the differential comparison alone
+ applies).
+5. **Normalize tree preservation** (`normalize-tree-changed`), only on a
+ clean baseline: parsing `normalize()` output must produce the same tree as
+ the original input, modulo the documented scalar substitutions. This is
+ stricter than idempotence, which a consistently wrong serializer can pass.
+6. **Normalize idempotence** (`normalize-invariant-failed`):
+ `normalize()` / `serialize()` run twice must be a fixed point, with no
+ PHP native errors or throwables. Full documents use
+ `create_full_parser()->serialize()`; non-body fragment contexts use
+ `create_fragment()->serialize()`.
+
+### Known invariant oracle follow-ups
+
+- The simple `set_attribute()` mutation oracle needs to handle inputs that
+ begin with `` using the same tag-selection semantics as the mutator.
+ `next_tag()` skips the raw closing token and mutates the following tag,
+ while a verifier that scans with `next_token()` can see the spec-special
+ `BR` element synthesized from `` first and incorrectly report
+ `mutation-attribute-missing`. Fix the verifier by selecting the first
+ mutable tag through `next_tag()` too.
+
+## Generator Profiles
+
+The generator uses a structural HTML grammar with weighted profiles:
+
+- `balanced`
+- `full-document` (includes occasional frameset documents, quirks-mode
+ doctypes, and content after ``)
+- `body-fragment`
+- `tables`
+- `template`
+- `select` (option/optgroup nesting, select-ending elements such as `input`,
+ `textarea` and `button`, nested selects, select-in-table)
+- `foreign-content` (MathML/SVG integration points, HTML breakout tags,
+ `` with and without breakout attributes, `annotation-xml` encoding
+ variants, CDATA sections in foreign content, case-mangled `foreignObject`)
+- `rawtext-rcdata` (script/style/iframe/noembed/noframes/xmp/noscript,
+ title/textarea, occasional `plaintext`)
+- `text-fragment` (standalone terminal payloads, biased toward exact
+ 0-10 byte inputs plus medium syntax-heavy text unless `stress-long` is
+ selected explicitly)
+- `formatting-adoption` (random formatting elements plus explicit
+ adoption-agency shapes: misnested closers, block-boundary formatting,
+ reconstruction across siblings, nested anchors, Noah's Ark overflow,
+ repeated closers, and four-plus active-formatting reconstruction clusters
+ covering same tag/no attributes, same tag/distinct attributes, same
+ tag/matching attributes, mixed interleavings, and table-cell marker
+ isolation/clearing)
+- `attributes-entities`
+- `comments-doctype-bogus`
+- `deep-nesting`
+- `resource-stress`
+- `incomplete-malformed` (includes spec-special closers such as `` and
+ `