Coverage for /pythoncovmergedfiles/medio/medio/src/turbohtml/tools/fuzz/round_trip_oracles.py: 46%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1283 statements  

1""" 

2Round-trip and cross-entry-point oracles: the ``--mode round-trip`` lane of ``tools/fuzz/fuzz.py``. 

3 

4A printer or minifier bug usually returns wrong text without crashing, so each oracle checks a property every output 

5must keep. A printed language parses back to the tree it came from and prints again unchanged (HTML, XML, CSS, JS, 

6style declarations, Markdown through markdown-it-py). A minifier keeps what the input means: JS keeps its free 

7identifiers and static strings, read by acorn and eslint-scope; CSS keeps every element's computed style and every 

8selector's match set. Entry points that answer one question agree, and source spans stay ordered and shift exactly 

9with a prefix. 

10 

11Every oracle carries negative controls that feed it a deliberately broken printer, minifier or entry point and must 

12fire, and a vacuity floor on the share of in-scope cases it compared, so an oracle that stops detecting or stops 

13comparing fails the run (DESIGN-v2 §4d). A finding lands in ``--crash-dir`` as ``crash-<sha256>`` with a JSON sidecar; 

14the log names only the oracle and the hash, because CI logs on a public repository are public. 

15""" 

16 

17from __future__ import annotations 

18 

19import argparse 

20import hashlib 

21import json 

22import math 

23import os 

24import random 

25import re 

26import shutil 

27import subprocess 

28import sys 

29import time 

30import unicodedata 

31from collections import Counter 

32from dataclasses import dataclass, field 

33from functools import cache, partial 

34from itertools import pairwise, starmap 

35from pathlib import Path 

36from typing import TYPE_CHECKING, Final, Literal 

37from urllib.parse import urlsplit 

38 

39import tinycss2 

40from fuzz.css_custom_oracles import ( 

41 UnsupportedCssCustomCaseError, 

42 css_custom_check, 

43 css_custom_controls, 

44 css_custom_generate, 

45 css_custom_seeds, 

46) 

47from fuzz.css_structure_generators import css_generate, css_source_generate, css_source_seeds 

48from fuzz.dom_lifecycle import ( 

49 UnsupportedDomLifecycleError, 

50 dom_lifecycle_check, 

51 dom_lifecycle_controls, 

52 dom_lifecycle_generate, 

53 dom_lifecycle_seeds, 

54) 

55from fuzz.dom_program import ( 

56 UnsupportedDomProgramError, 

57 dom_program_check, 

58 dom_program_controls, 

59 dom_program_generate, 

60 dom_program_seeds, 

61) 

62from fuzz.html_foreign_oracles import ( 

63 UnsupportedHtmlForeignCaseError, 

64 html_foreign_check, 

65 html_foreign_controls, 

66 html_foreign_generate, 

67 html_foreign_seeds, 

68) 

69from fuzz.html_grammar_oracles import ( 

70 UnsupportedHtmlSiblingCaseError, 

71 html_sibling_check, 

72 html_sibling_controls, 

73 html_sibling_generate, 

74 html_sibling_seeds, 

75) 

76from fuzz.html_list_oracles import ( 

77 UnsupportedHtmlListCaseError, 

78 html_list_check, 

79 html_list_controls, 

80 html_list_generate, 

81 html_list_seeds, 

82) 

83from fuzz.html_structure_generators import html_generate 

84from fuzz.html_table_oracles import ( 

85 UnsupportedHtmlTableCaseError, 

86 html_table_check, 

87 html_table_controls, 

88 html_table_generate, 

89 html_table_seeds, 

90) 

91from fuzz.idna_nfc_oracles import ( 

92 UnsupportedIdnaNfcCaseError, 

93 idna_nfc_check, 

94 idna_nfc_controls, 

95 idna_nfc_generate, 

96 idna_nfc_seeds, 

97) 

98from fuzz.iterator_oracles import ( 

99 UnsupportedIteratorCaseError, 

100 iterator_sequence_check, 

101 iterator_sequence_controls, 

102 iterator_sequence_generate, 

103 iterator_sequence_seeds, 

104) 

105from fuzz.markdown_structure_generators import ( 

106 MarkdownProfileError, 

107 markdown_controls, 

108 markdown_generate, 

109 markdown_html_check, 

110 markdown_html_meaning, 

111 markdown_source_check, 

112 markdown_source_seeds, 

113) 

114from fuzz.observer_oracles import ( 

115 UnsupportedObserverCaseError, 

116 observer_sequence_check, 

117 observer_sequence_controls, 

118 observer_sequence_generate, 

119 observer_sequence_seeds, 

120) 

121from fuzz.parser_byte_oracles import ( 

122 UnsupportedParserCaseError, 

123 parser_bytes_check, 

124 parser_bytes_controls, 

125 parser_bytes_generate, 

126 parser_bytes_seeds, 

127) 

128from fuzz.reduce import minimize 

129from fuzz.xml_grammar_oracles import ( 

130 UnsupportedXmlLiteralCaseError, 

131 xml_literal_check, 

132 xml_literal_controls, 

133 xml_literal_generate, 

134 xml_literal_seeds, 

135) 

136from fuzz.xml_island_oracles import ( 

137 UnsupportedXmlIslandCaseError, 

138 xml_island_check, 

139 xml_island_controls, 

140 xml_island_generate, 

141 xml_island_seeds, 

142) 

143from fuzz.xml_structure_generators import ( 

144 xml_document_check, 

145 xml_source_controls, 

146 xml_source_generate, 

147 xml_source_seeds, 

148) 

149from markdown_it import MarkdownIt 

150from tinycss2.ast import AtRule, ParseError, QualifiedRule 

151from typing_extensions import override 

152 

153from turbohtml import ( 

154 CData, 

155 Comment, 

156 Doctype, 

157 Document, 

158 DocumentFragment, 

159 Element, 

160 Html, 

161 HTMLParseError, 

162 IncrementalParser, 

163 Namespace, 

164 Node, 

165 ProcessingInstruction, 

166 SelectorSyntaxError, 

167 SourceLocation, 

168 SourceSpan, 

169 Text, 

170 XPath, 

171 parse, 

172 parse_fragment, 

173 parse_xml, 

174) 

175from turbohtml.clean import JSMinify, minify_css, minify_js 

176from turbohtml.convert import ExpressionError, css_to_xpath 

177from turbohtml.cssom import StyleDeclaration, StyleSheet, computed_style 

178from turbohtml.detect import EncodingDetector, EncodingMatch, detect 

179from turbohtml.extract import clean_url, normalize_url 

180from turbohtml.query import Matcher 

181from turbohtml.query import compile as compile_selector 

182 

183if TYPE_CHECKING: 

184 from collections.abc import Callable, Iterable, Sequence 

185 from urllib.parse import SplitResult 

186 

187__all__ = [ 

188 "MAX_INPUT", 

189 "ORACLES", 

190 "UNBOUNDED", 

191 "Floor", 

192 "JsNames", 

193 "Oracle", 

194 "OutOfScopeError", 

195 "clean_url_check", 

196 "css_semantics_check", 

197 "encoding_decode_check", 

198 "encoding_stream_check", 

199 "fixpoint_check", 

200 "html_check", 

201 "idna_host_check", 

202 "js_names_check", 

203 "main", 

204 "markdown_check", 

205 "normalize_url_check", 

206 "resolve_links_check", 

207 "selector_entry_check", 

208 "span_check", 

209 "style_check", 

210 "url_reparse_check", 

211 "xml_check", 

212 "xpath_entry_check", 

213] 

214 

215_ROOT: Final[Path] = Path(__file__).resolve().parents[2] 

216_NODE_DIR: Final[Path] = _ROOT / "tools" / "bench" / "node" 

217_MINIMIZE_BUDGET: Final = 600 

218# TH_MAX_TREE_DEPTH (src/turbohtml/_c/dom/tree.h): past 512 open elements a start tag is inserted but not pushed, so 

219# further tags become siblings and reparsing the flattened output nests them differently 

220_MAX_TREE_DEPTH: Final = 512 

221# the CSS minifier caps nesting at 100 levels and drops a deeper block (docs/changelog.rst, 1.13.0) 

222_CSS_MAX_NESTING: Final = 100 

223# tree.h raises this RecursionError for a walk past its depth budget; the one documented depth error 

224_DEPTH_ERROR: Final = re.compile(r"does not support trees nested \d+ levels or deeper") 

225# Script text where "<!--" opens and "<script" plus a tag end follows before any "-->" breaks the script content 

226# restrictions (https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements): 

227# reparsing its serialization lands in the double-escaped state, which swallows the closing "</script>". The standard 

228# warns that such parser-made trees need not survive serialize and reparse 

229# (https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments). 

230_SCRIPT_DOUBLE_ESCAPE: Final = re.compile(r"<!--(?:(?!-->).)*?<script[\t\n\f />]", re.IGNORECASE | re.DOTALL) 

231# rust-cssparser dropped its idempotence assert because dtoa takes two passes to converge (9999995e-45 -> 10e-39 -> 

232# 1e-38), so a numeric rewrite may settle on the second pass: 

233# https://github.com/servo/rust-cssparser/blob/106021c7bc55ed2cac83f590c0d356fa530b7061/fuzz/fuzz_targets/cssparser.rs 

234_NUMBER: Final = re.compile(r"(?<![\w$#\\.])[+-]?(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:[eE][+-]?\d+)?n?") 

235_JS_OFF: Final = JSMinify(mangle=False, fold=False) 

236_MAX_RENESTS: Final = 5 

237# cap one case's input: generators stay under a few KiB, but a mutated .dat seed can grow, and to_markdown/to_text 

238# amplify nested input about 100-240x (tox-dev/turbohtml#1028), so an input this long could print hundreds of MiB 

239MAX_INPUT: Final = 64 * 1024 

240# parse() allocates without bound on a selectedcontent nested in its own option (findings/01); the generators never 

241# emit the tag, so skipping it keeps a machine-safe run while the defect is tracked on its own 

242UNBOUNDED: Final = re.compile(r"selectedcontent", re.IGNORECASE) 

243_HEADINGS: Final = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"}) 

244_SELF_NESTING: Final[dict[str, frozenset[str]]] = { 

245 **{tag: frozenset({tag}) for tag in ("a", "nobr", "p", "button", "select", "form", "table", "option", "li")}, 

246 **dict.fromkeys(("dd", "dt"), frozenset({"dd", "dt"})), 

247 **dict.fromkeys(_HEADINGS, _HEADINGS), 

248 **dict.fromkeys(("rb", "rt", "rp", "rtc"), frozenset({"rb", "rt", "rp", "rtc"})), 

249} 

250_MARKDOWN_BLOCKS: Final = frozenset({ 

251 "address", "article", "aside", "blockquote", "dd", "details", "div", "dl", "dt", "fieldset", "figure", "footer", 

252 "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", 

253 "table", "ul", 

254}) # fmt: skip 

255# the elements whose Markdown form holds only inline content: phrasing elements and GFM table cells 

256_MARKDOWN_HOSTS: Final = frozenset({ 

257 "a", "abbr", "b", "bdi", "bdo", "cite", "code", "del", "dfn", "em", "i", "ins", "kbd", "mark", "q", "s", "samp", 

258 "small", "span", "strike", "strong", "sub", "sup", "td", "th", "u", "var", 

259}) # fmt: skip 

260 

261 

262class _FaithfulMarkdown(MarkdownIt): 

263 """markdown-it percent-encodes link destinations and drops ``javascript:`` links, reader policy this undoes.""" 

264 

265 @override 

266 def normalizeLink(self, url: str) -> str: 

267 return url 

268 

269 @override 

270 def validateLink(self, url: str) -> bool: 

271 return True 

272 

273 

274_MARKDOWN: Final = _FaithfulMarkdown("gfm-like", {"linkify": False}) 

275# markdown-it-py 4.2.0 strips a paragraph with str.strip (rules_block/paragraph.py), so a paragraph of only U+00A0 

276# reads as an empty one, where CommonMark strips only spaces and tabs; an empty paragraph shows nothing either way 

277_EMPTY_PARAGRAPH: Final = re.compile(r"<p></p>\n") 

278 

279 

280class OutOfScopeError(Exception): 

281 """The case is outside the oracle's scope; it counts against the oracle's vacuity floor.""" 

282 

283 

284@dataclass(frozen=True) 

285class Floor: 

286 """ 

287 The least an oracle must compare for a run to mean anything. 

288 

289 Modeled on meriyah's ``MINIMUM_COMPARED_RATIO``: ``ratio`` bounds compared cases over compared plus skipped ones, 

290 ``count`` bounds the compared cases themselves. 

291 """ 

292 

293 count: int 

294 ratio: float 

295 

296 

297@dataclass(frozen=True) 

298class Oracle: 

299 """ 

300 One wrong-output oracle. 

301 

302 ``check`` returns None when the case holds, a shape-only description when it breaks, and raises 

303 :class:`OutOfScopeError` when the case is out of scope. ``controls`` maps each negative control to whether it fired. 

304 A case that is a JSON object names the string ``fields`` to shrink one at a time; any other case shrinks whole. 

305 """ 

306 

307 check: Callable[[str], str | None] 

308 generate: Callable[[random.Random], str] 

309 seeds: Callable[[], list[str]] 

310 controls: Callable[[], dict[str, bool]] 

311 floor: Floor 

312 fields: tuple[str, ...] = () 

313 syntax: Literal["html", "css", "js"] | None = None 

314 

315 

316def main(argv: Sequence[str] | None = None) -> int: 

317 """Return 0 when every oracle holds, 1 on a finding, 2 when a control stayed silent or a run compared too little.""" 

318 parser = argparse.ArgumentParser(description=__doc__) 

319 parser.add_argument("--minutes", type=float, default=1.0, help="generation budget after the seed pass") 

320 parser.add_argument( 

321 "--rng-seed", 

322 type=int, 

323 default=int(os.environ.get("FUZZ_RNG_SEED", "0")), 

324 help="seed of the generated cases (default: $FUZZ_RNG_SEED, else 0)", 

325 ) 

326 parser.add_argument("--crash-dir", type=Path, default=_ROOT / ".fuzz-crashes", help="where findings land") 

327 parser.add_argument("--oracle", action="append", choices=sorted(ORACLES), help="run only these (repeatable)") 

328 args = parser.parse_args(argv) 

329 

330 names = args.oracle or sorted(ORACLES) 

331 if blind := [ 

332 f"{name}/{control}" for name in names for control, fired in ORACLES[name].controls().items() if not fired 

333 ]: 

334 print(f"BLIND ORACLE: the negative control did not fire for {', '.join(blind)}", file=sys.stderr) 

335 return 2 

336 args.crash_dir.mkdir(parents=True, exist_ok=True) 

337 run = _Run(args.crash_dir) 

338 for name in names: 

339 for seed in ORACLES[name].seeds(): 

340 run.case(name, seed) 

341 rng = random.Random(args.rng_seed) 

342 deadline = time.monotonic() + args.minutes * 60 

343 while time.monotonic() < deadline: 

344 name = rng.choice(names) 

345 run.case(name, ORACLES[name].generate(rng)) 

346 for name in names: 

347 print(f"{name}: {dict(run.stats[name])}") 

348 findings = run.report(args.rng_seed) 

349 if vacuous := [name for name in names if not _meets(ORACLES[name].floor, run.stats[name])]: 

350 print(f"VACUOUS RUN: {', '.join(vacuous)} compared below the floor", file=sys.stderr) 

351 return 2 

352 print(f"{findings} finding(s) written to {args.crash_dir}") 

353 return int(findings > 0) 

354 

355 

356def _meets(floor: Floor, stats: Counter[str]) -> bool: 

357 compared = stats["compared"] 

358 return compared >= floor.count and compared >= floor.ratio * (compared + stats["skipped"]) 

359 

360 

361@dataclass 

362class _Found: 

363 oracle: str 

364 detail: str 

365 text: str 

366 hits: int = 1 

367 

368 

369@dataclass 

370class _Run: 

371 crash_dir: Path 

372 stats: dict[str, Counter[str]] = field(default_factory=lambda: {name: Counter() for name in ORACLES}) 

373 found: dict[tuple[str, str], _Found] = field(default_factory=dict) 

374 

375 def case(self, name: str, text: str) -> None: 

376 if len(text) > MAX_INPUT or UNBOUNDED.search(text): 

377 self.stats[name]["skipped"] += 1 

378 return 

379 # an ASan abort kills the process, so the input it died on must already be on disk 

380 (self.crash_dir / "current_input").write_text(text, encoding="utf-8", errors="surrogatepass") 

381 try: 

382 detail = _guarded(ORACLES[name].check, text) 

383 except OutOfScopeError: 

384 self.stats[name]["skipped"] += 1 

385 return 

386 self.stats[name]["compared"] += 1 

387 if detail is not None: 

388 if (key := (name, detail)) in self.found: 

389 self.found[key].hits += 1 

390 else: 

391 self.found[key] = _Found(name, detail, text) 

392 

393 def report(self, rng_seed: int) -> int: 

394 for found in self.found.values(): 

395 oracle = ORACLES[found.oracle] 

396 text = _minimize(found.text, partial(_reproduces, oracle.check, found.detail), oracle.fields, oracle.syntax) 

397 data = text.encode("utf-8", "surrogatepass") 

398 digest = hashlib.sha256(data).hexdigest() 

399 (self.crash_dir / f"crash-{digest}").write_bytes(data) 

400 sidecar = {"oracle": found.oracle, "detail": found.detail, "hits": found.hits, "rng_seed": rng_seed} 

401 (self.crash_dir / f"crash-{digest}.json").write_text(json.dumps(sidecar, indent=2) + "\n", encoding="utf-8") 

402 # only the oracle and a hash reach the log: the input may be an unreported bug (DESIGN-v2 decision D5) 

403 print(f"FINDING {found.oracle} sha256={digest}", file=sys.stderr, flush=True) 

404 return len(self.found) 

405 

406 

407def _guarded(check: Callable[[str], str | None], text: str) -> str | None: 

408 """Run one check, turning the documented depth error into a skip and any other escape into a finding.""" 

409 try: 

410 return check(text) 

411 except RecursionError as error: 

412 if _DEPTH_ERROR.search(str(error)): 

413 raise OutOfScopeError from error 

414 return "raised RecursionError" 

415 except OutOfScopeError: 

416 raise 

417 except Exception as error: # ruff: ignore[blind-except] # an unexpected exception type from turbohtml is itself the finding 

418 return f"raised {type(error).__name__}" 

419 

420 

421def _reproduces(check: Callable[[str], str | None], detail: str, text: str) -> bool: 

422 try: 

423 return _guarded(check, text) == detail 

424 except OutOfScopeError: 

425 return False 

426 

427 

428def _minimize( 

429 text: str, 

430 reproduces: Callable[[str], bool], 

431 fields: Sequence[str], 

432 syntax: Literal["html", "css", "js"] | None, 

433) -> str: 

434 remaining = _MINIMIZE_BUDGET 

435 

436 def check(candidate: str) -> bool: 

437 nonlocal remaining 

438 remaining -= 1 

439 return reproduces(candidate) 

440 

441 def shrink(source: str, predicate: Callable[[str], bool], language: Literal["html", "css", "js"] | None) -> str: 

442 if language is not None: 

443 source = minimize(source, predicate, language, remaining) 

444 return _ddmin(source, predicate, remaining) 

445 

446 if not fields: 

447 return shrink(text, check, syntax) 

448 payload = json.loads(text) 

449 for name in fields: 

450 payload[name] = shrink( 

451 payload[name], 

452 lambda value, name=name: check(json.dumps({**payload, name: value})), 

453 "html" if name == "html" else syntax, 

454 ) 

455 return json.dumps(payload) 

456 

457 

458def _ddmin(text: str, reproduces: Callable[[str], bool], budget: int) -> str: 

459 """Shrink ``text`` while ``reproduces`` holds, with Zeller and Hildebrandt's ddmin under a call budget.""" 

460 calls = 0 

461 granularity = 2 

462 while len(text) >= 2 and calls < budget: 

463 chunk = max(len(text) // granularity, 1) 

464 for start in range(0, len(text), chunk): 

465 if calls == budget: 

466 return text 

467 calls += 1 

468 if reproduces(candidate := text[:start] + text[start + chunk :]): 

469 text = candidate 

470 granularity = max(granularity - 1, 2) 

471 break 

472 else: 

473 if chunk == 1: 

474 break 

475 granularity = min(granularity * 2, len(text)) 

476 return text 

477 

478 

479def fixpoint_check(text: str, printer: Callable[[str], str], *, numeric: bool) -> str | None: 

480 """ 

481 Require ``printer`` to reach a fixpoint: printing its own output changes nothing. 

482 

483 With ``numeric``, a second pass that only rewrites numbers may settle on the third (``f(f(f(x))) == f(f(x))``), the 

484 rust-cssparser exception; anything else that moves on the second pass is a finding. A first pass that rejects the 

485 input skips it; a later pass that rejects the printer's own output is a finding. 

486 """ 

487 try: 

488 once = printer(text) 

489 except ValueError as error: 

490 raise OutOfScopeError from error 

491 try: 

492 twice = printer(once) 

493 except ValueError: 

494 return "rejects its own output" 

495 if twice == once: 

496 return None 

497 if numeric and _NUMBER.sub("0", twice) == _NUMBER.sub("0", once) and printer(twice) == twice: 

498 return None 

499 return f"not a fixpoint: {_divergence(once, twice)}" 

500 

501 

502def _html_foreign_check(case: str) -> str | None: 

503 try: 

504 return html_foreign_check(case) 

505 except UnsupportedHtmlForeignCaseError as error: 

506 raise OutOfScopeError from error 

507 

508 

509def _html_list_check(case: str) -> str | None: 

510 try: 

511 return html_list_check(case) 

512 except UnsupportedHtmlListCaseError as error: 

513 raise OutOfScopeError from error 

514 

515 

516def _html_table_check(case: str) -> str | None: 

517 try: 

518 return html_table_check(case) 

519 except UnsupportedHtmlTableCaseError as error: 

520 raise OutOfScopeError from error 

521 

522 

523def _html_sibling_check(case: str) -> str | None: 

524 try: 

525 return html_sibling_check(case) 

526 except UnsupportedHtmlSiblingCaseError as error: 

527 raise OutOfScopeError from error 

528 

529 

530def _css_custom_check(case: str) -> str | None: 

531 try: 

532 return css_custom_check(case) 

533 except UnsupportedCssCustomCaseError as error: 

534 raise OutOfScopeError from error 

535 

536 

537def _observer_sequence_check(case: str) -> str | None: 

538 try: 

539 return observer_sequence_check(case) 

540 except UnsupportedObserverCaseError as error: 

541 raise OutOfScopeError from error 

542 

543 

544def _dom_lifecycle_check(case: str) -> str | None: 

545 try: 

546 return dom_lifecycle_check(case) 

547 except UnsupportedDomLifecycleError as error: 

548 raise OutOfScopeError from error 

549 

550 

551def _dom_program_check(case: str) -> str | None: 

552 try: 

553 return dom_program_check(case) 

554 except UnsupportedDomProgramError as error: 

555 raise OutOfScopeError from error 

556 

557 

558def _iterator_sequence_check(case: str) -> str | None: 

559 try: 

560 return iterator_sequence_check(case) 

561 except UnsupportedIteratorCaseError as error: 

562 raise OutOfScopeError from error 

563 

564 

565_IDNA_LABELS: Final[dict[str, tuple[str, str]]] = { 

566 "umlaut": ("münchen", "münchen"), 

567 "sharp-s": ("faß", "faß"), 

568 "acute-decomposed": ("a\u0301", "á"), 

569 "acute-precomposed": ("á", "á"), 

570 "umlaut-decomposed": ("u\u0308", "ü"), 

571 "japanese": ("日本語", "日本語"), 

572 "greek": ("δοκιμή", "δοκιμή"), 

573 "ignored": ("ex\u00adample", "example"), 

574 "fullwidth": ("\uff26\uff2f\uff2f", "foo"), 

575 "mapped-dot": ("日本語。\uff2a\uff30", "日本語.jp"), 

576} 

577 

578 

579def idna_host_check(case: str, normalize: Callable[[str], str] = normalize_url) -> str | None: 

580 """Pin supported Unicode 16 host mappings through the public URL normalizer.""" 

581 variant, separator, affix = case.partition("\n") 

582 if (mapping := _IDNA_LABELS.get(variant)) is None or not separator or not re.fullmatch(r"[a-z0-9]{0,8}", affix): 

583 raise OutOfScopeError(case) 

584 source, mapped = mapping 

585 mapped = affix + mapped 

586 expected: Final = ( 

587 "https://" 

588 + ".".join( 

589 label if label.isascii() else "xn--" + label.encode("punycode").decode("ascii") 

590 for label in mapped.split(".") 

591 ) 

592 + ".example/" 

593 ) 

594 try: 

595 once = normalize(f"https://{affix}{source}.example/") 

596 except ValueError: 

597 return "rejects known-valid Unicode host" 

598 if (problem := _idna_output_problem(once)) is not None: 

599 return problem 

600 if once != expected: 

601 return "IDNA host differs from expected mapping" 

602 try: 

603 twice = normalize(once) 

604 except ValueError: 

605 return "rejects canonical IDNA output" 

606 return None if twice == once else "IDNA output is not a fixpoint" 

607 

608 

609def _idna_output_problem(once: str) -> str | None: 

610 if not once.isascii(): 

611 return "IDNA output is not ASCII" 

612 for label in once.removeprefix("https://").removesuffix("/").split("."): 

613 if not label.startswith("xn--"): 

614 continue 

615 payload = label[4:] 

616 try: 

617 decoded = payload.encode("ascii").decode("punycode") 

618 except UnicodeError: 

619 return "IDNA output has undecodable Punycode" 

620 if not unicodedata.is_normalized("NFC", decoded): 

621 return "IDNA decoded label is not NFC" 

622 if decoded.encode("punycode").decode("ascii") != payload: 

623 return "IDNA Punycode is not canonical" 

624 return None 

625 

626 

627def _idna_nfc_check(case: str) -> str | None: 

628 try: 

629 return idna_nfc_check(case) 

630 except UnsupportedIdnaNfcCaseError as error: 

631 raise OutOfScopeError(str(error)) from error 

632 

633 

634def normalize_url_check(text: str, normalize: Callable[[str], str] = normalize_url) -> str | None: 

635 """URL normalization promises a stable representation after one successful pass.""" 

636 return fixpoint_check(text, normalize, numeric=False) 

637 

638 

639def url_reparse_check(text: str, normalize: Callable[[str], str] = normalize_url) -> str | None: 

640 """Preserve delimiter presence when checking serialization through a reference split.""" 

641 try: 

642 normalized: Final = normalize(text) 

643 parts: Final = urlsplit(normalized) 

644 except ValueError as error: 

645 raise OutOfScopeError(str(error)) from error 

646 recomposed: Final = _url_recompose(parts, normalized) 

647 if recomposed != normalized: 

648 return f"split/recompose changes normalized URL: {_divergence(normalized, recomposed)}" 

649 try: 

650 reparsed: Final = normalize(recomposed) 

651 except ValueError: 

652 return "rejects recomposed output" 

653 return None if reparsed == normalized else f"reparse changes normalized URL: {_divergence(normalized, reparsed)}" 

654 

655 

656def _url_recompose(parts: SplitResult, text: str) -> str: 

657 body: Final = text[len(parts.scheme) + 1 :] if parts.scheme else text 

658 return "".join(( 

659 f"{parts.scheme}:" if parts.scheme else "", 

660 f"//{parts.netloc}" if body.startswith("//") else "", 

661 parts.path, 

662 f"?{parts.query}" if "?" in body.partition("#")[0] else "", 

663 f"#{parts.fragment}" if "#" in body else "", 

664 )) 

665 

666 

667def clean_url_check(text: str, clean: Callable[[str], str | None] = clean_url) -> str | None: 

668 """Skip rejected inputs; flag rejection of a cleaned URL.""" 

669 try: 

670 once = clean(text) 

671 except ValueError as error: 

672 raise OutOfScopeError from error 

673 if once is None: 

674 raise OutOfScopeError(text) 

675 try: 

676 twice = clean(once) 

677 except ValueError: 

678 return "rejects its own output" 

679 if twice is None: 

680 return "rejects its own output" 

681 return None if twice == once else f"not a fixpoint: {_divergence(once, twice)}" 

682 

683 

684_ENCODING_FORMATS: Final[dict[str, tuple[str, bytes, str, str]]] = { 

685 "utf-8-bom": ("utf-8", b"\xef\xbb\xbf", "UTF-8-SIG", ""), 

686 "utf-16le-bom": ("utf-16-le", b"\xff\xfe", "UTF-16LE", ""), 

687 "utf-16be-bom": ("utf-16-be", b"\xfe\xff", "UTF-16BE", ""), 

688 "utf-16-meta-remap": ("utf-8", b"", "UTF-8", "<meta charset=utf-16>"), 

689 "utf-16le-meta-remap": ("utf-8", b"", "UTF-8", "<meta charset=utf-16le>"), 

690 "utf-16be-meta-remap": ("utf-8", b"", "UTF-8", "<meta charset=utf-16be>"), 

691 "utf-8-meta": ("utf-8", b"", "UTF-8", "<meta charset=utf-8>"), 

692 "windows-1252-meta": ("cp1252", b"", "windows-1252", "<meta charset=windows-1252>"), 

693 "latin1-meta-alias": ("cp1252", b"", "windows-1252", "<meta charset=latin1>"), 

694 "iso-8859-1-meta-alias": ("cp1252", b"", "windows-1252", "<meta charset=iso-8859-1>"), 

695 "us-ascii-meta-alias": ("cp1252", b"", "windows-1252", "<meta charset=us-ascii>"), 

696 "utf-8-bom-conflicting-meta": ("utf-8", b"\xef\xbb\xbf", "UTF-8-SIG", "<meta charset=windows-1252>"), 

697 "utf-16le-bom-conflicting-meta": ("utf-16-le", b"\xff\xfe", "UTF-16LE", "<meta charset=utf-8>"), 

698 "utf-16be-bom-conflicting-meta": ("utf-16-be", b"\xfe\xff", "UTF-16BE", "<meta charset=utf-8>"), 

699 "utf-8-pragma-first": ("utf-8", b"", "UTF-8", '<meta http-equiv=Content-Type content="text/html; charset=utf-8">'), 

700 "utf-8-content-first": ("utf-8", b"", "UTF-8", '<meta content="text/html; charset=utf-8" http-equiv=Content-Type>'), 

701 "windows-1252-pragma-first": ( 

702 "cp1252", 

703 b"", 

704 "windows-1252", 

705 '<meta http-equiv=Content-Type content="text/html; charset=windows-1252">', 

706 ), 

707 "windows-1252-content-first": ( 

708 "cp1252", 

709 b"", 

710 "windows-1252", 

711 '<meta content="text/html; charset=windows-1252" http-equiv=Content-Type>', 

712 ), 

713} 

714 

715 

716def encoding_stream_check( 

717 case: str, 

718 one_shot: Callable[[bytes], EncodingMatch] = detect, 

719 stream: Callable[[bytes, int], EncodingMatch] | None = None, 

720) -> str | None: 

721 """Pin expected detection independently of the chunk-boundary comparison.""" 

722 expected: Final = _encoding_case(case) 

723 if one_shot(expected.data) != expected.match: 

724 return "one-shot detection differs from expected match" 

725 for width in range(1, 6): 

726 if (stream or _encoding_stream)(expected.data, width) != expected.match: 

727 return "chunked detection differs from expected match" 

728 return None 

729 

730 

731def encoding_decode_check( 

732 case: str, 

733 detect_bytes: Callable[[bytes], EncodingMatch] = detect, 

734 decode: Callable[[bytes, str], str] = bytes.decode, 

735 parse_text: Callable[[bytes], str | None] | None = None, 

736) -> str | None: 

737 """Require the codec and byte parser to preserve fixture text.""" 

738 expected: Final = _encoding_case(case) 

739 if expected.match.codec is None: 

740 raise OutOfScopeError(case) 

741 if detect_bytes(expected.data) != expected.match: 

742 return "detection differs from expected match" 

743 if decode(expected.data, expected.match.codec) != expected.decoded: 

744 return "detected codec differs from expected text" 

745 if (parse_text or _encoding_text)(expected.data) != expected.text: 

746 return "byte parse differs from expected text" 

747 return None 

748 

749 

750def _encoding_case(case: str) -> _EncodingCase: 

751 variant, separator, text = case.partition("\n") 

752 if not separator or len(text) > 256 or (text and not text.isprintable()) or any(char in text for char in "<>&"): 

753 raise OutOfScopeError(case) 

754 if variant == "empty" and not text: 

755 return _EncodingCase(b"", EncodingMatch(None, 0.0, None), "", "") 

756 if (formatting := _ENCODING_FORMATS.get(variant)) is None: 

757 raise OutOfScopeError(case) 

758 codec, prefix, label, meta = formatting 

759 markup: Final = f"{meta}<p>{text}</p>" 

760 try: 

761 data: Final = prefix + markup.encode(codec) 

762 except UnicodeEncodeError as error: 

763 raise OutOfScopeError from error 

764 decoded: Final = ("\ufeff" if label in {"UTF-16LE", "UTF-16BE"} else "") + markup 

765 return _EncodingCase(data, EncodingMatch(label, 1.0, None, bool(prefix), f"whatwg-{label.lower()}"), decoded, text) 

766 

767 

768@dataclass(frozen=True) 

769class _EncodingCase: 

770 data: bytes 

771 match: EncodingMatch 

772 decoded: str 

773 text: str 

774 

775 

776def _encoding_stream(data: bytes, width: int) -> EncodingMatch: 

777 detector: Final = EncodingDetector() 

778 detector.feed(b"") 

779 for start in range(0, len(data), width): 

780 detector.feed(data[start : start + width]) 

781 detector.feed(b"") 

782 return detector.close() 

783 

784 

785def _encoding_text(data: bytes) -> str: 

786 return parse(data).text 

787 

788 

789def _divergence(left: str, right: str) -> str: 

790 """Name the character classes at the first difference, so findings that differ only in payload share a key.""" 

791 pairs = enumerate(zip(left, right, strict=False)) 

792 index = next((position for position, (one, two) in pairs if one != two), min(len(left), len(right))) 

793 return f"{_char_class(left[index : index + 1])} vs {_char_class(right[index : index + 1])}" 

794 

795 

796def _char_class(char: str) -> str: 

797 if not char: 

798 return "end" 

799 if char.isalpha(): 

800 return "letter" 

801 if char.isdigit(): 

802 return "digit" 

803 if char.isspace(): 

804 return "space" 

805 return repr(char) if char in "<>&\"'=/;:{}()[],.!*#`\\|_-+~$@%^?" else "other" 

806 

807 

808def html_check(markup: str, serialize: Callable[[Node], str] = Node.serialize) -> str | None: 

809 """ 

810 Require HTML serialization to keep a tree's content and reach a fixpoint, for a document and a ``<div>`` fragment. 

811 

812 The standard warns that parser-made trees need not survive serialize and reparse 

813 (https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments): nested ``<a>``, a ``<table>`` in 

814 a quirks-mode ``<p>`` and the like re-nest. So the first reparse may move elements but must keep every text run, 

815 comment and element with its attributes, and the second serialization must equal the first byte for byte. A tree 

816 flattened past the depth cap, script text that enters the double-escaped state, and a ``plaintext`` element (its 

817 serialized end tag is text to the PLAINTEXT state, which never exits) skip. A carriage return compares as a line 

818 feed: serialization emits it raw and input preprocessing turns it into one, and text holding one skips, since the 

819 line feed it becomes can then be the one a ``pre`` drops after its start tag. Each reparse may re-nest further, so a 

820 fixpoint must come within five. 

821 """ 

822 for label, tree, reparse in ( 

823 ("document", parse(markup), parse), 

824 ("fragment", parse_fragment(markup), parse_fragment), 

825 ): 

826 if _reparse_may_differ(tree): 

827 raise OutOfScopeError 

828 content = _content(tree) 

829 printed = _newlines(_print(tree, serialize)) 

830 for _ in range(_MAX_RENESTS): 

831 if (again := _print(reparsed := reparse(printed), serialize)) == printed: 

832 break 

833 if _content(reparsed) != content: 

834 return f"{label} reparse changed content" 

835 printed = again 

836 else: 

837 return f"{label} not a fixpoint after {_MAX_RENESTS} reparses" 

838 return None 

839 

840 

841def _print(tree: Node, serialize: Callable[[Node], str]) -> str: 

842 return serialize(tree) if isinstance(tree, Document) else "".join(serialize(child) for child in tree.children) 

843 

844 

845def _reparse_may_differ(root: Node) -> bool: 

846 return ( 

847 _past_depth_cap(root) 

848 or any(_SCRIPT_DOUBLE_ESCAPE.search(script.text) for script in root.iter_elements("script")) 

849 or any(element.namespace is Namespace.HTML for element in root.iter_elements("plaintext")) 

850 or any(isinstance(node, Text) and "\r" in node.data for node in root.descendants) 

851 or any(_nests_in_itself(element) for element in root.iter_elements()) 

852 ) 

853 

854 

855def _nests_in_itself(element: Element) -> bool: 

856 # a start tag of these names closes, re-nests or drops an open element of its group (WHATWG 13.2.6.4.7 "in 

857 # body": the adoption agency for a, nobr; "close a p element"; li, dd, dt, option, headings, button, select, 

858 # form's element pointer, table, ruby), so a parser-made tree nesting one inside its group cannot be re-read 

859 group = _SELF_NESTING.get(element.tag) 

860 return ( 

861 group is not None 

862 and element.namespace is Namespace.HTML 

863 and any(getattr(ancestor, "tag", None) in group for ancestor in element.ancestors) 

864 ) 

865 

866 

867def _content(root: Node) -> tuple[str, list[str], set[tuple[str, str, tuple[tuple[str, str], ...]]]]: 

868 """ 

869 Reduce a tree to what re-nesting keeps: its text in order, its comments in order, its distinct elements. 

870 

871 A bare ``p`` or ``br`` does not count: re-nesting can strand a ``</p>`` or meet a ``</br>``, and the tree builder 

872 answers each with a new empty element (WHATWG 13.2.6.4.7, "in body"). 

873 """ 

874 nodes = list(root.descendants) 

875 elements = { 

876 ( 

877 element.namespace.value, 

878 element.tag, 

879 tuple(sorted((name, _attr_text(value)) for name, value in element.attrs.items())), 

880 ) 

881 for element in root.iter_elements() 

882 } 

883 return ( 

884 _newlines("".join(node.data for node in nodes if isinstance(node, Text))), 

885 [node.data for node in nodes if isinstance(node, Comment)], 

886 elements - {("html", "p", ()), ("html", "br", ())}, 

887 ) 

888 

889 

890def _newlines(text: str) -> str: 

891 return text.replace("\r\n", "\n").replace("\r", "\n") 

892 

893 

894def _past_depth_cap(root: Node) -> bool: 

895 # the root node plus 512 ancestors is the deepest placement the tree builder allows, reached only on a full stack 

896 return any(sum(1 for _ in element.ancestors) > _MAX_TREE_DEPTH for element in root.iter_elements()) 

897 

898 

899def _xml(node: Node) -> str: 

900 return node.serialize(Html(xml=True)) 

901 

902 

903def xml_check(markup: str, serialize: Callable[[Node], str] = _xml) -> str | None: 

904 """ 

905 Require ``serialize(Html(xml=True))`` output to re-read with ``parse_xml`` into the tree it came from. 

906 

907 The string must also print unchanged. Content the ``Html.xml`` docstring says the serializer drops or spaces (C0 

908 controls, surrogates, U+FFFE/U+FFFF, ``--`` or a trailing ``-`` in a comment, non-XML attribute names) waives only 

909 the tree comparison. 

910 """ 

911 document = parse(markup) 

912 if sum(isinstance(node, Element) for node in document.children) != 1 or any( 

913 isinstance(node, Text) for node in document.children 

914 ): 

915 raise OutOfScopeError 

916 if _xml_has_unbound_prefix(document): 

917 raise OutOfScopeError 

918 try: 

919 reread = parse_xml(once := serialize(document)) 

920 except (ValueError, HTMLParseError): 

921 return "parse_xml rejects the XML serialization" 

922 if (twice := serialize(reread)) != once: 

923 return f"xml not a fixpoint: {_divergence(once, twice)}" 

924 if _xml_lossy(document): 

925 return None 

926 expected, got = [*_xml_dump(document), "#end"], [*_xml_dump(reread), "#end"] 

927 if expected == got: 

928 return None 

929 return "xml re-read tree differs: " + next( 

930 f"{want[:1]} vs {have[:1]}" for want, have in zip(expected, got, strict=False) if want != have 

931 ) 

932 

933 

934def _xml_has_unbound_prefix(document: Document) -> bool: 

935 for element in document.iter_elements(): 

936 for name in (element.tag, *element.attrs): 

937 if ":" not in name or name.startswith((":", "xmlns:")) or name.endswith(":") or name.count(":") > 1: 

938 continue 

939 prefix = name.split(":", 1)[0] 

940 if prefix == "xml" or (prefix == "xlink" and element.namespace is not Namespace.HTML): 

941 continue 

942 if not any( 

943 isinstance(ancestor, Element) and ancestor.attrs.get(f"xmlns:{prefix}") 

944 for ancestor in (element, *element.ancestors) 

945 ): 

946 return True 

947 return False 

948 

949 

950# XML 1.0 Char (https://www.w3.org/TR/xml/#NT-Char) and Name (https://www.w3.org/TR/xml/#NT-Name) productions 

951_XML_CHAR: Final = re.compile(r"[^\t\n\r\x20-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]") 

952_XML_PUBID: Final = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 \r\n-'()+,./:=?;!*#@$_%") 

953_NAME_START: Final = ( 

954 r":A-Z_a-z\xc0-\xd6\xd8-\xf6\xf8-\u02ff\u0370-\u037d\u037f-\u1fff\u200c\u200d\u2070-\u218f\u2c00-\u2fef" 

955 r"\u3001-\ud7ff\uf900-\ufdcf\ufdf0-\ufffd\U00010000-\U000effff" 

956) 

957_XML_NAME: Final = re.compile(rf"[{_NAME_START}][{_NAME_START}\-.0-9\xb7\u0300-\u036f\u203f-\u2040]*") 

958 

959 

960def _xml_lossy(document: Document) -> bool: 

961 for node in document.descendants: 

962 if isinstance(node, Doctype) and _xml_doctype_lossy(node): 

963 return True 

964 if isinstance(node, (Text, CData, ProcessingInstruction)) and _XML_CHAR.search(node.data): 

965 return True 

966 if isinstance(node, Comment) and (_XML_CHAR.search(node.data) or "--" in node.data or node.data.endswith("-")): 

967 return True 

968 if isinstance(node, Element) and (_XML_NAME.fullmatch(node.tag) is None or ":" in node.tag): 

969 return True 

970 if isinstance(node, Element) and any( 

971 _XML_NAME.fullmatch(name) is None 

972 or name.startswith(":") 

973 or name.endswith(":") 

974 or name.count(":") > 1 

975 or (":" in name and not name.startswith("xmlns:")) 

976 or _XML_CHAR.search(_attr_text(value)) 

977 for name, value in node.attrs.items() 

978 ): 

979 return True 

980 return False 

981 

982 

983def _xml_doctype_lossy(node: Doctype) -> bool: 

984 if _XML_NAME.fullmatch(node.name) is None: 

985 return True 

986 if node.public_id is not None and ( 

987 node.system_id is None or any(character not in _XML_PUBID for character in node.public_id) 

988 ): 

989 return True 

990 return node.system_id is not None and _XML_CHAR.search(node.system_id) is not None 

991 

992 

993def _attr_text(value: str | list[str] | None) -> str: 

994 return " ".join(value) if isinstance(value, list) else value or "" 

995 

996 

997def _xml_dump(root: Node) -> list[str]: 

998 """ 

999 Flatten a tree to comparable lines: element names with their non-namespace attributes, merged text, comments. 

1000 

1001 ``parse_xml`` reports every element in the HTML namespace and keeps ``xmlns`` declarations as attributes, and a 

1002 template's content fragment holds what XML reads as the template's children, so those three are normalized away. 

1003 """ 

1004 out: list[str] = [] 

1005 text = "" 

1006 stack: list[Node | str] = list(reversed(root.children)) 

1007 while stack: 

1008 if isinstance(node := stack.pop(), (Text, CData)): 

1009 text += node.data 

1010 continue 

1011 if text: 

1012 out.append(f'"{text}') 

1013 text = "" 

1014 if isinstance(node, str): 

1015 out.append(node) 

1016 elif isinstance(node, (Element, DocumentFragment)): 

1017 if isinstance(node, Element): 

1018 out.append(f"<{node.tag} {_xml_attrs(node)}") 

1019 stack.append(f"</{node.tag}") 

1020 stack.extend(reversed(node.children)) 

1021 else: 

1022 out.append(_xml_leaf(node)) 

1023 if text: 

1024 out.append(f'"{text}') 

1025 return out 

1026 

1027 

1028def _xml_attrs(element: Element) -> list[tuple[str, str]]: 

1029 return sorted( 

1030 (name, _attr_text(value)) 

1031 for name, value in element.attrs.items() 

1032 if name != "xmlns" and not name.startswith("xmlns:") 

1033 ) 

1034 

1035 

1036def _xml_leaf(node: Node) -> str: 

1037 if isinstance(node, Comment): 

1038 return f"!{node.data}" 

1039 if isinstance(node, ProcessingInstruction): 

1040 return f"?{node.target} {node.data}" 

1041 if isinstance(node, Doctype): 

1042 # DOM Parsing 3.2.1.3 writes an external ID only when non-empty, so empty and absent read the same 

1043 return f"D{node.name} {node.public_id or None} {node.system_id or None}" 

1044 return type(node).__name__ 

1045 

1046 

1047def resolve_links_check(text: str, resolve: Callable[[Node, str], None] = Node.resolve_links) -> str | None: 

1048 """Check fixed RFC targets because stable wrong resolutions satisfy a fixed-point check.""" 

1049 case, separator, leaf = text.partition(":") 

1050 if not separator or case not in _RESOLUTION_CASES or re.fullmatch(r"[a-z][a-z0-9]{0,15}", leaf) is None: 

1051 raise OutOfScopeError(text) 

1052 reference, target = _RESOLUTION_CASES[case] 

1053 document = parse_fragment('<a href="">link</a>') 

1054 document.select("a")[0].attrs["href"] = reference.format(leaf=leaf) 

1055 resolve(document, _RESOLUTION_BASE) 

1056 expected: Final = target.format(leaf=leaf) 

1057 if document.links()[0].url != expected: 

1058 return "resolution differs from RFC target" 

1059 resolve(document, _RESOLUTION_BASE) 

1060 return None if document.links()[0].url == expected else "resolved link changes on repeat" 

1061 

1062 

1063def style_check(text: str, read: Callable[[str], StyleDeclaration] = StyleDeclaration.parse) -> str | None: 

1064 """Require ``StyleDeclaration.text`` to re-read to the same declarations and print unchanged.""" 

1065 first = read(text) 

1066 second = read(once := first.text) 

1067 if _winning(first) != _winning(second): 

1068 return "declarations differ after re-read" 

1069 if (twice := second.text) != once: 

1070 return f"not a fixpoint: {_divergence(once, twice)}" 

1071 return None 

1072 

1073 

1074def _winning(declaration: StyleDeclaration) -> list[tuple[str, str | None, bool]]: 

1075 return [(name, declaration.get(name), declaration.important(name)) for name in declaration.properties()] 

1076 

1077 

1078def markdown_check(markup: str, render: Callable[[Node], str] = Node.to_markdown) -> str | None: 

1079 """ 

1080 Require ``to_markdown`` output to mean, to markdown-it-py's GFM reader, what its own re-rendering means. 

1081 

1082 ``m1 = to_markdown(html)`` is read into ``h1``, and ``m2 = to_markdown(h1)`` must read into ``h1`` again: the 

1083 Markdown tree is the one compared, so spelling choices (which characters get escaped, how many blank lines) do not 

1084 count, while emphasis delimiters that merge, a table that reads as a paragraph, or a character left unescaped that 

1085 starts a construct do. CommonMark has no syntax for a block inside an inline or inside a GFM table cell, so a tree 

1086 that nests one (a ``<pre>`` in a ``<del>``, as the parser builds from misnested tags) has no faithful Markdown form 

1087 and skips. 

1088 """ 

1089 document = parse(markup) 

1090 if any( 

1091 element.tag in _MARKDOWN_BLOCKS 

1092 and any(getattr(ancestor, "tag", "") in _MARKDOWN_HOSTS for ancestor in element.ancestors) 

1093 for element in document.iter_elements() 

1094 ): 

1095 raise OutOfScopeError 

1096 if any( 

1097 element.tag == "br" 

1098 and isinstance(element.previous_sibling, Element) 

1099 and element.previous_sibling.tag == "br" 

1100 and any(getattr(ancestor, "tag", "") in _MARKDOWN_HOSTS for ancestor in element.ancestors) 

1101 for element in document.iter_elements() 

1102 ): 

1103 raise OutOfScopeError 

1104 if any( 

1105 element.tag == "br" 

1106 and any(getattr(ancestor, "tag", "") in {"td", "th"} for ancestor in element.ancestors) 

1107 and any( 

1108 getattr(ancestor, "tag", "") in {"em", "strong", "del", "s", "strike"} for ancestor in element.ancestors 

1109 ) 

1110 for element in document.iter_elements() 

1111 ): 

1112 raise OutOfScopeError 

1113 first = _MARKDOWN.render(render(document)) 

1114 if (second := _MARKDOWN.render(render(parse(first)))) != first and _markdown_html(second) != _markdown_html(first): 

1115 try: 

1116 if markdown_html_meaning(second) != markdown_html_meaning(first): 

1117 return f"not a fixpoint: {_divergence(first, second)}" 

1118 except MarkdownProfileError as error: 

1119 raise OutOfScopeError from error 

1120 return None 

1121 

1122 

1123def _markdown_html(markup: str) -> str: 

1124 markup = _EMPTY_PARAGRAPH.sub("", markup) 

1125 markup = re.sub(r"<li>\n?<p>(.*?)</p>\n?</li>", r"<li>\1</li>", markup, flags=re.DOTALL) 

1126 markup = markup.replace("<del>", "<s>").replace("</del>", "</s>") 

1127 markup = re.sub(r"<br\s*/?>[ \t]*\n?", "<br>", markup) 

1128 markup = markup.replace("<li>\n<br>", "<li><br>") 

1129 markup = re.sub(r"((?:<(?:strong|em|s)>)+)<br>", r"<br>\1", markup) 

1130 markup = re.sub(r"<br>((?:</(?:strong|em|s)>)+)", r"\1<br>", markup) 

1131 return re.sub(r"<(strong|em|s)></\1>", "", markup) 

1132 

1133 

1134class JsNames: 

1135 """ 

1136 The free identifiers and static strings of a JavaScript source, read by acorn and eslint-scope in one Node process. 

1137 

1138 ``tools/bench/node/js_names_runner.js`` defines both sets. Kept apart from turbohtml's own JS front end on purpose: 

1139 a reader that shared the minifier's lexer would share its bugs. 

1140 """ 

1141 

1142 def __init__(self, runner_dir: Path = _NODE_DIR) -> None: 

1143 """Start nothing yet; the Node process runs ``js_names_runner.js`` from ``runner_dir`` on the first read.""" 

1144 self._runner_dir = runner_dir 

1145 self._process: subprocess.Popen[str] | None = None 

1146 

1147 def read(self, source: str) -> tuple[frozenset[str], frozenset[str]] | None: 

1148 """ 

1149 Return ``(free identifiers, static strings)``, or None when acorn rejects ``source``. 

1150 

1151 :raises OutOfScopeError: eslint-scope could not scope the tree acorn built. 

1152 """ 

1153 process = self._start() 

1154 assert process.stdin is not None # ruff: ignore[assert] # Popen with stdin=PIPE always sets it 

1155 assert process.stdout is not None # ruff: ignore[assert] # Popen with stdout=PIPE always sets it 

1156 process.stdin.write(json.dumps(source) + "\n") 

1157 process.stdin.flush() 

1158 if (answer := json.loads(process.stdout.readline())) is None: 

1159 return None 

1160 if "error" in answer: 

1161 raise OutOfScopeError(answer["error"]) 

1162 return frozenset(answer["free"]), frozenset(answer["strings"]) 

1163 

1164 def _start(self) -> subprocess.Popen[str]: 

1165 if self._process is None: 

1166 if (node := shutil.which("node")) is None or not ( 

1167 self._runner_dir / "node_modules" / "eslint-scope" 

1168 ).is_dir(): 

1169 msg = f"node and `npm ci` in {self._runner_dir} are required by the js-names oracle" 

1170 raise FileNotFoundError(msg) 

1171 self._process = subprocess.Popen( 

1172 [node, str(self._runner_dir / "js_names_runner.js")], 

1173 stdin=subprocess.PIPE, 

1174 stdout=subprocess.PIPE, 

1175 text=True, 

1176 encoding="utf-8", 

1177 errors="surrogatepass", 

1178 cwd=self._runner_dir, 

1179 # an ASan-preloaded run must not hand its runtime to node 

1180 env={ 

1181 key: value 

1182 for key, value in os.environ.items() 

1183 if key not in {"LD_PRELOAD", "DYLD_INSERT_LIBRARIES"} 

1184 }, 

1185 ) 

1186 return self._process 

1187 

1188 

1189_OPTION_SETS: Final = tuple(JSMinify(mangle=mangle, fold=fold) for fold in (False, True) for mangle in (False, True)) 

1190 

1191 

1192def js_names_check(source: str, names: JsNames, minify: Callable[[str, JSMinify], str] = minify_js) -> str | None: 

1193 """ 

1194 Require ``minify_js`` to invent and lose no free identifier and no static string. 

1195 

1196 With ``mangle`` and ``fold`` off both sets must match exactly. ``fold`` deletes dead code and concatenates strings 

1197 and ``mangle`` drops unused declarations, so with either on no free identifier may appear that the input lacked. 

1198 Every output must parse as JavaScript. 

1199 """ 

1200 if (before := names.read(source)) is None: 

1201 raise OutOfScopeError 

1202 compared = False 

1203 for options in _OPTION_SETS: 

1204 try: 

1205 output = minify(source, options) 

1206 except ValueError: 

1207 continue 

1208 compared = True 

1209 label = f"mangle={options.mangle} fold={options.fold}" 

1210 if (after := names.read(output)) is None: 

1211 return f"{label}: output is not JavaScript" 

1212 if options.fold or options.mangle: 

1213 if after[0] - before[0]: 

1214 return f"{label}: free identifier invented" 

1215 elif after[0] != before[0]: 

1216 return f"{label}: free identifier {'invented' if after[0] - before[0] else 'lost'}" 

1217 elif after[1] != before[1]: 

1218 return f"{label}: static string {'invented' if after[1] - before[1] else 'lost'}" 

1219 if not compared: 

1220 raise OutOfScopeError 

1221 return None 

1222 

1223 

1224def css_semantics_check( 

1225 markup: str, minify: Callable[[str], str] = minify_css, *, stylesheet: str | None = None 

1226) -> str | None: 

1227 """ 

1228 Require ``minify_css`` to keep every element's computed style and every selector's match set. 

1229 

1230 cssnano's fuzzCheck model: the document's ``<style>`` sheet is minified in place and ``computed_style`` of each 

1231 element compared. ``computed_style`` returns values as written and keeps invalid ones, so each pair is read with 

1232 the CSS value grammar first: two valid values must be equal values, and a value the grammar rejects must not become 

1233 one it accepts. A value outside what this reader covers makes no claim. Each rule's selector is also minified on its 

1234 own and must select the same elements. 

1235 """ 

1236 document = parse(markup) 

1237 style = _css_style(document, stylesheet) 

1238 sheet = style.text 

1239 _require_supported_css_shorthands(sheet) 

1240 elements = list(document.iter_elements()) 

1241 before = [computed_style(element) for element in elements] 

1242 style.text = minify(sheet) 

1243 after = [computed_style(element) for element in elements] 

1244 for want, got in zip(before, after, strict=True): 

1245 for name in want.properties(): 

1246 if (verdict := _value_verdict(name, want[name], got[name])) is not None: 

1247 return f"computed {_VALUE_KIND.get(name, 'keyword')}: {verdict}" 

1248 for rule in StyleSheet(sheet).rules: 

1249 try: 

1250 expected = document.select(rule.selector_text) 

1251 except SelectorSyntaxError: 

1252 continue 

1253 minified = StyleSheet(minify(f"{rule.selector_text}{{color:red}}")).rules 

1254 if len(minified) != 1: 

1255 return "selector rule dropped" 

1256 try: 

1257 if document.select(minified[0].selector_text) != expected: 

1258 return "selector match set differs" 

1259 except SelectorSyntaxError: 

1260 return "minified selector does not parse" 

1261 return None 

1262 

1263 

1264def _require_supported_css_shorthands(sheet: str) -> None: 

1265 for rule in StyleSheet(sheet).rules: 

1266 for name in ("margin", "padding", "border-width"): 

1267 if (value := rule.style.get(name)) is not None and any( 

1268 _read_value("border-top-width" if name == "border-width" else f"{name}-top", part) is _INVALID 

1269 for part in value.split() 

1270 ): 

1271 raise OutOfScopeError 

1272 

1273 

1274def _css_style(document: Document, stylesheet: str | None) -> Element: 

1275 if (style := document.select_one("style")) is None: 

1276 raise OutOfScopeError 

1277 if stylesheet is not None: 

1278 style.text = stylesheet 

1279 return style 

1280 

1281 

1282def _value_verdict(name: str, before: str, after: str) -> str | None: 

1283 if before == after: 

1284 return None 

1285 want, got = _read_value(name, before), _read_value(name, after) 

1286 if want is _INVALID: 

1287 return None if got is None or got is _INVALID else "invalid value became valid" 

1288 if want is None or got is None: 

1289 return None 

1290 if got is _INVALID: 

1291 return "valid value became invalid" 

1292 return None if want == got else "value changed" 

1293 

1294 

1295_INVALID: Final = ("invalid",) 

1296_SIDES: Final = ("top", "right", "bottom", "left") 

1297_VALUE_KIND: Final[dict[str, str]] = { 

1298 **dict.fromkeys( 

1299 ("color", "background-color", "outline-color", *(f"border-{side}-color" for side in _SIDES)), "color" 

1300 ), 

1301 **dict.fromkeys( 

1302 ( 

1303 *_SIDES, 

1304 *(f"{box}-{side}" for box in ("margin", "padding") for side in _SIDES), 

1305 *(f"border-{side}-width" for side in _SIDES), 

1306 *(f"{bound}-{axis}" for bound in ("min", "max") for axis in ("width", "height")), 

1307 "width", 

1308 "height", 

1309 "text-indent", 

1310 "letter-spacing", 

1311 "word-spacing", 

1312 "font-size", 

1313 "outline-width", 

1314 ), 

1315 "length", 

1316 ), 

1317 "line-height": "line-height", 

1318 "z-index": "integer", 

1319 "opacity": "alpha", 

1320 "font-weight": "weight", 

1321} 

1322_LENGTH_UNITS: Final = frozenset({ 

1323 "px", "em", "rem", "ex", "ch", "vw", "vh", "vmin", "vmax", "cm", "mm", "q", "in", "pt", "pc", "%", 

1324}) # fmt: skip 

1325_NUMERIC: Final = re.compile(r"([+-]?(?:\d+\.\d+|\d+|\.\d+)(?:[eE][+-]?\d+)?)([a-zA-Z]+|%)?") 

1326# CSS Syntax 3 4.3.12: a "." starts a fraction only before a digit, so "1." is a number and a stray "." delim 

1327_TRAILING_DOT: Final = re.compile(r"[+-]?\d+\.(?:[a-zA-Z]+|%)?") 

1328_NAMED_COLORS: Final = { 

1329 "red": (255, 0, 0, 1.0), 

1330 "white": (255, 255, 255, 1.0), 

1331 "black": (0, 0, 0, 1.0), 

1332 "navy": (0, 0, 128, 1.0), 

1333 "transparent": (0, 0, 0, 0.0), 

1334} 

1335 

1336 

1337def _read_value(name: str, value: str) -> object: 

1338 """ 

1339 Read one computed value with its property's grammar, for the properties the generator writes. 

1340 

1341 Returns a comparable value, ``_INVALID`` when the grammar rejects it, or None when it falls outside what this reader 

1342 covers. Sources: CSS Values 4 (lengths, a unitless zero, integers), CSS Color 4 (hex, ``rgb()`` legacy and modern 

1343 syntax, named colors, opacity percentages), CSS Fonts 4 (``bold`` and ``normal`` are 700 and 400). 

1344 """ 

1345 text = value.strip() 

1346 kind = _VALUE_KIND.get(name) 

1347 if _TRAILING_DOT.fullmatch(text): 

1348 return _INVALID 

1349 if kind == "color": 

1350 return _read_color(text.lower()) 

1351 if (numeric := _NUMERIC.fullmatch(text)) is not None: 

1352 return _read_number(kind, numeric[1], (numeric[2] or "").lower()) 

1353 keyword = text.lower() 

1354 if re.fullmatch(r"-?[a-z][a-z-]*", keyword) is None: 

1355 return None 

1356 return {"bold": 700.0, "normal": 400.0}.get(keyword, keyword) if kind == "weight" else keyword 

1357 

1358 

1359def _read_number(kind: str | None, digits: str, unit: str) -> object: 

1360 number = float(digits) 

1361 readers: dict[str | None, Callable[[], object]] = { 

1362 "integer": lambda: int(number) if not unit and re.fullmatch(r"[+-]?\d+", digits) else _INVALID, 

1363 "alpha": lambda: {"": number, "%": number / 100}.get(unit, _INVALID), 

1364 "weight": lambda: number if not unit and 1 <= number <= 1000 else _INVALID, 

1365 "length": lambda: _read_length(number, unit), 

1366 "line-height": lambda: ("number", number) if not unit else _read_length(number, unit), 

1367 } 

1368 return readers.get(kind, lambda: None)() 

1369 

1370 

1371def _read_length(number: float, unit: str) -> object: 

1372 if unit and unit not in _LENGTH_UNITS: 

1373 return _INVALID 

1374 if number == 0 and unit != "%": 

1375 return ("length", 0.0, "") 

1376 return ("length", number, unit) if unit else _INVALID 

1377 

1378 

1379def _read_color(text: str) -> object: 

1380 if text in _NAMED_COLORS: 

1381 return _NAMED_COLORS[text] 

1382 if text.startswith("#"): 

1383 digits = text[1:] 

1384 if re.fullmatch(r"[0-9a-f]+", digits) is None or len(digits) not in {3, 4, 6, 8}: 

1385 return _INVALID 

1386 full = "".join(digit * 2 for digit in digits) if len(digits) < 6 else digits 

1387 channels = [int(full[index : index + 2], 16) for index in range(0, len(full), 2)] 

1388 return (*channels[:3], round(channels[3] / 255, 3) if len(channels) == 4 else 1.0) 

1389 if (function := re.fullmatch(r"rgba?\((.*)\)", text, re.DOTALL)) is None or re.search(r"[a-z(]", function[1]): 

1390 return None # other color functions, calc(), var() and none need a full value parser 

1391 return _read_rgb(function[1]) 

1392 

1393 

1394def _read_rgb(arguments: str) -> object: 

1395 """Read ``rgb()`` arguments: three comma- or space-separated channels and an alpha, after ``/`` in the latter.""" 

1396 parts = [part.strip() for part in arguments.split(",")] 

1397 legacy = len(parts) > 1 

1398 if not legacy: 

1399 modern = re.fullmatch(r"(\S+)\s+(\S+)\s+(\S+)(?:\s*/\s*(\S+))?", parts[0]) 

1400 parts = [] if modern is None else [part for part in modern.groups() if part is not None] 

1401 elif any(re.search(r"\s", part) for part in parts): 

1402 parts = [] 

1403 numbers = [match for part in parts if (match := _NUMERIC.fullmatch(part)) is not None and match[2] in {None, "%"}] 

1404 if len(parts) not in {3, 4} or len(numbers) != len(parts): 

1405 return _INVALID 

1406 if legacy and len({match[2] for match in numbers[:3]}) != 1: 

1407 return _INVALID # the comma syntax takes three numbers or three percentages, never a mix 

1408 channels = [round(min(max(float(match[1]) * (2.55 if match[2] else 1), 0), 255)) for match in numbers[:3]] 

1409 alpha = 1.0 if len(numbers) == 3 else float(numbers[3][1]) / (100 if numbers[3][2] else 1) 

1410 return (*channels, round(min(max(alpha, 0.0), 1.0), 3)) 

1411 

1412 

1413# several spellings of one value each, so the generator writes values the minifier rewrites 

1414_VALUE_SPELLINGS: Final[dict[str, tuple[str, ...]]] = { 

1415 "red": ("red", "#f00", "#ff0000", "#F00", "#FF0000", "rgb(255,0,0)", "rgb(255 0 0)", "#ff0000ff", "#f00f"), 

1416 "white": ("white", "#fff", "#ffffff", "#FFF", "rgb(255,255,255)", "#ffffffff"), 

1417 "black": ("black", "#000", "#000000", "rgb(0,0,0)", "#000000ff"), 

1418 "navy": ("navy", "#000080", "rgb(0,0,128)"), 

1419 "transparent": ("transparent", "#0000", "#00000000", "rgba(0,0,0,0)"), 

1420 "0": ("0", "0px", "0em", "0.0px", "-0px", "+0px", "00px", ".0px", "0.0", "-0", "+0"), 

1421 "1.5px": ("1.5px", "1.50px", "+1.5px", "01.5px", "15e-1px", "0.15e1px"), 

1422 "10px": ("10px", "1e1px", "10.0px", "+10px", "010px"), 

1423 "1": ("1", "+1", "01"), 

1424 "0.5": ("0.5", ".5", "0.50", "+.5", "5e-1"), 

1425 "700": ("700", "bold"), 

1426 "400": ("400", "normal"), 

1427} 

1428 

1429 

1430def xpath_entry_check( 

1431 case: str, 

1432 iterate: Callable[[Node, str], Sequence[object]] = lambda node, expression: list(node.xpath_iter(expression)), 

1433 first: Callable[[Node, str], object] = lambda node, expression: node.xpath_one(expression), 

1434) -> str | None: 

1435 """ 

1436 Require ``xpath``, ``xpath_iter``, ``xpath_one`` and a compiled ``XPath`` to agree on one expression. 

1437 

1438 pugixml evaluates one query through all its result APIs; here ``list(xpath_iter(e)) == xpath(e)`` (a scalar result 

1439 is its single item), ``xpath_one(e)`` is the first item or None, ``XPath(e)(node) == xpath(e)``, and every entry 

1440 point raises the same exception type when one does. 

1441 """ 

1442 payload = json.loads(case) 

1443 document = parse(payload["html"]) 

1444 expression = payload["xpath"] 

1445 for context in [document, *list(document.iter_elements())[:6]]: 

1446 result = _outcome(lambda context=context: context.xpath(expression)) 

1447 compiled = _outcome(lambda context=context: XPath(expression)(context)) 

1448 iterated = _outcome(lambda context=context: iterate(context, expression)) 

1449 one = _outcome(lambda context=context: first(context, expression)) 

1450 if isinstance(result, _Raised): 

1451 if {type(outcome) for outcome in (compiled, iterated, one)} != {_Raised} or len({ 

1452 outcome.kind for outcome in (result, compiled, iterated, one) if isinstance(outcome, _Raised) 

1453 }) != 1: 

1454 return "entry points disagree on raising" 

1455 continue 

1456 items = result if isinstance(result, list) else [result] 

1457 if not _same(compiled, result): 

1458 return "XPath()() != xpath()" 

1459 if not _same(iterated, items): 

1460 return "list(xpath_iter()) != xpath()" 

1461 if not _same(one, items[0] if items else None): 

1462 return "xpath_one() != first item" 

1463 return None 

1464 

1465 

1466@dataclass(frozen=True) 

1467class _Raised: 

1468 kind: str 

1469 

1470 

1471def _outcome(call: Callable[[], object]) -> object: 

1472 try: 

1473 return call() 

1474 except (ValueError, TypeError) as error: 

1475 return _Raised(type(error).__name__) 

1476 

1477 

1478def _same(left: object, right: object) -> bool: 

1479 """Compare XPath results: nodes by identity (``Node.__eq__``), NaN equal to itself, lists item by item.""" 

1480 if isinstance(left, list) and isinstance(right, list): 

1481 return len(left) == len(right) and all(starmap(_same, zip(left, right, strict=False))) 

1482 if isinstance(left, float) and isinstance(right, float) and math.isnan(left) and math.isnan(right): 

1483 return True 

1484 return type(left) is type(right) and left == right 

1485 

1486 

1487def selector_entry_check(case: str, translate: Callable[[str], str] = css_to_xpath) -> str | None: 

1488 """ 

1489 Require the CSS entry points to agree on one selector over one document. 

1490 

1491 ``select(s) == xpath(css_to_xpath(s))`` from the document; a compiled ``Matcher`` selects, filters and matches what 

1492 ``Node.select`` and ``Node.matches`` do; a scoped select returns exactly the matching descendants, in order; and 

1493 ``closest`` finds the nearest matching ancestor-or-self. 

1494 """ 

1495 payload = json.loads(case) 

1496 document = parse(payload["html"]) 

1497 selector = payload["css"] 

1498 try: 

1499 selected = document.select(selector) 

1500 matcher = compile_selector(selector) 

1501 except SelectorSyntaxError as error: 

1502 raise OutOfScopeError from error 

1503 if (problem := _matcher_problem(document, selector, matcher, selected)) is not None: 

1504 return problem 

1505 try: 

1506 translated = translate(selector) 

1507 except ExpressionError: 

1508 return None 

1509 try: 

1510 xpath_selected = document.xpath(translated) 

1511 except ValueError as error: 

1512 if str(error) == "xpath: an expression nested too deeply": 

1513 raise OutOfScopeError from error 

1514 raise 

1515 return None if xpath_selected == selected else "xpath(css_to_xpath()) != select()" 

1516 

1517 

1518def _matcher_problem(document: Document, selector: str, matcher: Matcher, selected: list[Element]) -> str | None: 

1519 elements = list(document.iter_elements()) 

1520 # a node can come back as a fresh wrapper (free-threaded builds), so match by node equality, not id() 

1521 matching = set(selected) 

1522 first = selected[0] if selected else None 

1523 problems = ( 

1524 ([element for element in elements if element.matches(selector)] != selected, "matches() != select()"), 

1525 (matcher.select(document) != selected or matcher.filter(elements) != selected, "Matcher != select()"), 

1526 (first not in {matcher.select_one(document), document.select_one(selector)}, "select_one() != first match"), 

1527 ) 

1528 if (problem := next((label for broken, label in problems if broken), None)) is not None: 

1529 return problem 

1530 for scope in elements[:8]: 

1531 if scope.select(selector) != [element for element in scope.iter_elements() if element in matching]: 

1532 return "scoped select() != matching descendants" 

1533 nearest = next((element for element in [scope, *scope.ancestors] if element in matching), None) 

1534 if matcher.closest(scope) != nearest: 

1535 return "closest() != nearest matching ancestor" 

1536 return None 

1537 

1538 

1539def span_check( 

1540 markup: str, locate: Callable[[Element], SourceLocation | None] = lambda element: element.source_location 

1541) -> str | None: 

1542 """ 

1543 Require source spans that slice their constructs out of the source. 

1544 

1545 html5gum and Babel's position fuzzing check the same: every span lies inside the newline-normalized source with a 

1546 line and column that match its offset; a start tag span begins with ``<`` and the tag name, an end tag span with 

1547 ``</``, both end with ``>``; attribute spans sit inside their start tag and begin with the attribute name; distinct 

1548 tag spans never overlap; ``source_line``/``source_col`` name the start tag; a comment prefix shifts every span by 

1549 exactly its length; and an ``IncrementalParser`` fed in pieces reports the spans a one-shot parse does. 

1550 """ 

1551 source = markup.replace("\r\n", "\n").replace("\r", "\n") 

1552 document = parse(markup, source_locations=True) 

1553 elements = [element for element in document.iter_elements() if locate(element) is not None] 

1554 tokens: set[tuple[int, int]] = set() 

1555 for element in elements: 

1556 if (problem := _element_span_problem(source, element, locate)) is not None: 

1557 return problem 

1558 location = locate(element) 

1559 assert location is not None # ruff: ignore[assert] # filtered above 

1560 tokens.update((span.start_offset, span.end_offset) for span in (location.start_tag, location.end_tag) if span) 

1561 ordered = sorted(tokens) 

1562 if any(end > start for (_, end), (start, _) in pairwise(ordered)): 

1563 return "tag spans overlap" 

1564 prefix = f"<!--{chr(10) * (len(markup) % 3)}{'x' * (len(markup) % 5)}-->" 

1565 shifted = parse(prefix + markup, source_locations=True) 

1566 if [element.tag for element in shifted.iter_elements()] != [element.tag for element in document.iter_elements()]: 

1567 return "a comment prefix changed the tree" 

1568 expected = [_shift(locate(element), prefix) for element in document.iter_elements()] 

1569 if [locate(element) for element in shifted.iter_elements()] != expected: 

1570 return "a comment prefix shifted spans inexactly" 

1571 incremental = IncrementalParser(source_locations=True) 

1572 for start in range(0, len(markup), step := len(markup) // 3 + 1): 

1573 incremental.feed(markup[start : start + step]) 

1574 if [locate(element) for element in incremental.close().iter_elements()] != [ 

1575 locate(element) for element in document.iter_elements() 

1576 ]: 

1577 return "IncrementalParser spans != parse() spans" 

1578 return None 

1579 

1580 

1581def _element_span_problem( 

1582 source: str, element: Element, locate: Callable[[Element], SourceLocation | None] 

1583) -> str | None: 

1584 location = locate(element) 

1585 assert location is not None # ruff: ignore[assert] # the caller passes located elements only 

1586 start, end = location.start_tag, location.end_tag 

1587 if (problem := _span_problem(source, start)) is not None: 

1588 return f"start tag {problem}" 

1589 # the tokenizer reads a NUL in a tag or attribute name as U+FFFD (WHATWG 13.2.5.8, 13.2.5.33) 

1590 text = source[start.start_offset : start.end_offset].replace("\0", "\ufffd") 

1591 # the tree builder renames an "image" start tag to "img" (WHATWG 13.2.6.4.7, in body) 

1592 names = {element.tag.lower(), "image"} if element.tag == "img" else {element.tag.lower()} 

1593 if not text.endswith(">") or not any( 

1594 re.match(f"<{re.escape(name)}[\t\n\f />]", text, re.IGNORECASE) for name in names 

1595 ): 

1596 return "start tag span does not slice <name ...>" 

1597 if (element.source_line, element.source_col) != (start.start_line, start.start_col): 

1598 return "source_line/source_col != start tag start" 

1599 if end is not None and (problem := _end_tag_problem(source, start, end)) is not None: 

1600 return problem 

1601 return _attribute_span_problem(source, start, location.attrs) 

1602 

1603 

1604def _end_tag_problem(source: str, start: SourceSpan, end: SourceSpan) -> str | None: 

1605 if (problem := _span_problem(source, end)) is not None: 

1606 return f"end tag {problem}" 

1607 if end.start_offset < start.end_offset or not source[end.start_offset : end.end_offset].startswith("</"): 

1608 return "end tag span does not slice </name> after the start tag" 

1609 return None 

1610 

1611 

1612def _attribute_span_problem(source: str, start: SourceSpan, attrs: dict[str, SourceSpan]) -> str | None: 

1613 previous = start.start_offset 

1614 for name, span in sorted(attrs.items(), key=lambda item: item[1].start_offset): 

1615 if (problem := _span_problem(source, span)) is not None: 

1616 return f"attribute {problem}" 

1617 if span.start_offset < previous or span.end_offset > start.end_offset: 

1618 return "attribute span outside its start tag or overlapping" 

1619 if not source[span.start_offset : span.end_offset].replace("\0", "\ufffd").lower().startswith(name.lower()): 

1620 return "attribute span does not begin with its name" 

1621 previous = span.end_offset 

1622 return None 

1623 

1624 

1625def _span_problem(source: str, span: SourceSpan) -> str | None: 

1626 if not 0 <= span.start_offset < span.end_offset <= len(source): 

1627 return "span empty or outside the source" 

1628 for offset, line, column in ( 

1629 (span.start_offset, span.start_line, span.start_col), 

1630 (span.end_offset, span.end_line, span.end_col), 

1631 ): 

1632 if (line, column) != (source.count("\n", 0, offset) + 1, offset - source.rfind("\n", 0, offset) - 1): 

1633 return "line or column disagrees with offset" 

1634 return None 

1635 

1636 

1637def _shift(location: SourceLocation | None, prefix: str) -> SourceLocation | None: 

1638 if location is None: 

1639 return None 

1640 lines = prefix.count("\n") 

1641 tail = len(prefix) - prefix.rfind("\n") - 1 

1642 

1643 def move(span: SourceSpan) -> SourceSpan: 

1644 return SourceSpan( 

1645 span.start_line + lines, 

1646 span.start_col + tail * (span.start_line == 1), 

1647 span.start_offset + len(prefix), 

1648 span.end_line + lines, 

1649 span.end_col + tail * (span.end_line == 1), 

1650 span.end_offset + len(prefix), 

1651 ) 

1652 

1653 return SourceLocation( 

1654 move(location.start_tag), 

1655 None if location.end_tag is None else move(location.end_tag), 

1656 {name: move(span) for name, span in location.attrs.items()}, 

1657 ) 

1658 

1659 

1660def _css_fixpoint(text: str, printer: Callable[[str], str] = minify_css) -> str | None: 

1661 if _nesting(text) > _CSS_MAX_NESTING: 

1662 raise OutOfScopeError 

1663 return fixpoint_check(text, printer, numeric=True) 

1664 

1665 

1666def _css_valid_fixpoint(text: str, printer: Callable[[str], str] = minify_css) -> str | None: 

1667 if not _css_valid_source(text): 

1668 raise OutOfScopeError 

1669 return _css_fixpoint(text, printer) 

1670 

1671 

1672def _css_valid_source(text: str) -> bool: 

1673 tokens = tinycss2.parse_component_value_list(text) 

1674 recovered = tinycss2.serialize(tokens) 

1675 return not ( 

1676 _css_has_parse_error(tokens) 

1677 or any(recovered.count(delimiter) > text.count(delimiter) for delimiter in ")}]") 

1678 or (recovered.replace("/**/", "").startswith(text.replace("/**/", "")) and recovered != text) 

1679 or any(not _css_rule_valid(rule) for rule in tinycss2.parse_stylesheet(text)) 

1680 ) 

1681 

1682 

1683def _css_rule_valid(rule: object) -> bool: 

1684 if isinstance(rule, ParseError): 

1685 return False 

1686 if isinstance(rule, QualifiedRule): 

1687 try: 

1688 css_to_xpath(tinycss2.serialize(rule.prelude)) 

1689 except (ExpressionError, SelectorSyntaxError): 

1690 return False 

1691 return all(not isinstance(item, ParseError) for item in tinycss2.parse_declaration_list(rule.content)) 

1692 if ( 

1693 isinstance(rule, AtRule) 

1694 and rule.lower_at_keyword in {"media", "supports", "container", "layer"} 

1695 and rule.content is not None 

1696 ): 

1697 return all(_css_rule_valid(nested) for nested in tinycss2.parse_rule_list(rule.content)) 

1698 return True 

1699 

1700 

1701def _css_has_parse_error(tokens: list[object]) -> bool: 

1702 return any( 

1703 getattr(token, "type", "") == "error" 

1704 or _css_has_parse_error(getattr(token, "content", getattr(token, "arguments", []))) 

1705 for token in tokens 

1706 ) 

1707 

1708 

1709def _nesting(text: str) -> int: 

1710 depth = deepest = 0 

1711 for char in text: 

1712 depth += (char in "{([") - (char in "})]") 

1713 deepest = max(deepest, depth) 

1714 return deepest 

1715 

1716 

1717def _js_fixpoint(text: str) -> str | None: 

1718 # minify_js accepts some scripts acorn rejects (#984), whose output is not a program anyone wrote 

1719 if _JS_NAMES.read(text) is None: 

1720 raise OutOfScopeError 

1721 return fixpoint_check(text, lambda source: minify_js(source, _JS_OFF), numeric=True) 

1722 

1723 

1724_JS_NAMES: Final = JsNames() 

1725 

1726 

1727def _js_names(text: str) -> str | None: 

1728 return js_names_check(text, _JS_NAMES) 

1729 

1730 

1731@dataclass(frozen=True) 

1732class _Corpus: 

1733 html: tuple[str, ...] 

1734 css: tuple[str, ...] 

1735 js: tuple[str, ...] 

1736 

1737 

1738@cache 

1739def _corpus() -> _Corpus: 

1740 """Read the vendored seed inputs once; a missing source is a setup error, never a silent skip.""" 

1741 fuzz_corpus = _ROOT / "tools" / "fuzz" / "corpus" 

1742 regressions = _ROOT / "tests" / "fuzz_regressions" 

1743 tree_tests = _require(_ROOT / "tests" / "html5lib-tests" / "tree-construction") 

1744 html = [ 

1745 data 

1746 for path in sorted(tree_tests.glob("*.dat")) 

1747 for data in re.findall(r"^#data\n(.*?)\n#errors", path.read_text(encoding="utf-8"), re.MULTILINE | re.DOTALL) 

1748 ] 

1749 html_files = [ 

1750 *(path for name in ("parse", "roundtrip", "minify_html") for path in (fuzz_corpus / name).iterdir()), 

1751 *regressions.glob("*.html"), 

1752 ] 

1753 golden = _require(_ROOT / "tests" / "css" / "minify" / "css_minify_golden.json") 

1754 css_files = [*(fuzz_corpus / "minify_css").iterdir(), *regressions.glob("*.css")] 

1755 js = [ 

1756 row["input"] 

1757 for path in sorted(_require(_ROOT / "tests" / "js" / "_corpus").glob("*.json")) 

1758 for row in json.loads(path.read_text(encoding="utf-8")) 

1759 ] 

1760 return _Corpus( 

1761 tuple(dict.fromkeys([*html, *_texts(html_files)])), 

1762 tuple(dict.fromkeys([*(row[0] for row in json.loads(golden.read_text(encoding="utf-8"))), *_texts(css_files)])), 

1763 tuple(dict.fromkeys([*js, *_texts(regressions.glob("*.js"))])), 

1764 ) 

1765 

1766 

1767def _texts(paths: Iterable[Path]) -> list[str]: 

1768 return [path.read_text(encoding="utf-8") for path in sorted(paths)] 

1769 

1770 

1771def _require(path: Path) -> Path: 

1772 if not path.exists(): 

1773 msg = f"{path} is missing; tox -e fuzz-round-trip checks out the seed submodules" 

1774 raise FileNotFoundError(msg) 

1775 return path 

1776 

1777 

1778def _generated(make: Callable[[random.Random], str], count: int = 150) -> Callable[[], list[str]]: 

1779 """Seed an oracle with a fixed batch from its generator, the same on every run.""" 

1780 return lambda: [make(rng) for rng in [random.Random(0)] for _ in range(count)] 

1781 

1782 

1783def _mutate(rng: random.Random, text: str, tokens: Sequence[str]) -> str: 

1784 """Apply one to six edits: insert a token, delete or duplicate a slice, or flip a character's case.""" 

1785 for _ in range(rng.randint(1, 6)): 

1786 at = rng.randint(0, len(text)) 

1787 end = min(len(text), at + rng.randint(1, 8)) 

1788 draw = rng.random() 

1789 if draw < 0.5 or not text: 

1790 text = text[:at] + rng.choice(tokens) + text[at:] 

1791 elif draw < 0.7: 

1792 text = text[:at] + text[end:] 

1793 elif draw < 0.9: 

1794 text = text[:end] + text[at:end] + text[end:] 

1795 else: 

1796 text = text[:at] + text[at:end].swapcase() + text[end:] 

1797 return text 

1798 

1799 

1800_CLASSES: Final = ("a", "b", "c", "a b", "b c") 

1801_IDS: Final = ("x", "y", "z") 

1802 

1803 

1804_HTML_TOKENS: Final = ( 

1805 "<p>", "</p>", "<b>", "</b>", "<table>", "<td>", "<svg>", "<math>", "<template>", "<script>", "</script>", "<!--", 

1806 "-->", "<![CDATA[", "]]>", "<a href=x>", "</a>", "<select>", "<option>", "<br>", "</br>", "<image>", "&amp;", 

1807 "&#x0;", "\r", "\n", "<!DOCTYPE html>", "<frameset>", "<plaintext>", "<textarea>", "<style>", "<noscript>", 

1808 "<foreignObject>", "<desc>", "<mi>", "<annotation-xml encoding=text/html>", "<li>", "<dd>", "<form>", "</form>", 

1809 "<?x?>", "<x:y>", 'a="b"', "\x00", "<head>", "<body>", "</html>", "<col>", "<tr>", "<caption>", 

1810) # fmt: skip 

1811 

1812 

1813def _generate_html(rng: random.Random) -> str: 

1814 if rng.random() < 0.5: 

1815 return _mutate(rng, rng.choice(_corpus().html), _HTML_TOKENS) 

1816 return html_generate(rng).data.decode("utf-8") 

1817 

1818 

1819_MARKDOWN_TEXT: Final = ( 

1820 "word", "two words", "*", "_", "#", "|", "[", "]", "(", ")", "\\", "`", "&amp;", "&lt;", "&gt;", "1.", "2)", "-", 

1821 "+", "=", "~", "!", "<", ">", "'", '"', "&nbsp;", "---", "***", "```", "~~", "x*y", "a_b_c", "[x](y)", "\U0001f600", 

1822 "é", "http://e.x", " ", " ", 

1823) # fmt: skip 

1824 

1825 

1826def _markdown_source_generate(rng: random.Random) -> str: 

1827 return markdown_generate(rng).data.decode("utf-8") 

1828 

1829 

1830def _markdown_html_generate(rng: random.Random) -> str: 

1831 return markdown_generate(rng, html=True).data.decode("utf-8") 

1832 

1833 

1834def _markdown_source_check(source: str) -> str | None: 

1835 try: 

1836 return markdown_source_check(source) 

1837 except MarkdownProfileError as error: 

1838 raise OutOfScopeError from error 

1839 

1840 

1841def _markdown_html_check(source: str) -> str | None: 

1842 try: 

1843 return markdown_html_check(source) 

1844 except MarkdownProfileError as error: 

1845 raise OutOfScopeError from error 

1846 

1847 

1848def _markdown_document(rng: random.Random) -> str: 

1849 """ 

1850 Build conforming HTML that Markdown can express: flow blocks holding phrasing content, never a block inside it. 

1851 

1852 CommonMark has no syntax for a block inside an inline, so a parser-made tree that nests one (a ``<pre>`` inside a 

1853 ``<del>``) has no faithful Markdown form; the text in these blocks carries every Markdown-significant character. 

1854 """ 

1855 return "".join(_markdown_block(rng, 0) for _ in range(rng.randint(1, 4))) 

1856 

1857 

1858def _markdown_block(rng: random.Random, depth: int) -> str: 

1859 inline = partial(_markdown_inline, rng, 0) 

1860 choices: list[Callable[[], str]] = [ 

1861 lambda: f"<p>{inline()}</p>", 

1862 lambda: f"<p>{inline()}</p>", 

1863 lambda: f"<h{(level := rng.randint(1, 6))}>{inline()}</h{level}>", 

1864 lambda: f"<pre><code>{rng.choice(_MARKDOWN_TEXT)}\n{rng.choice(_MARKDOWN_TEXT)}</code></pre>", 

1865 lambda: "<hr>", 

1866 lambda: _markdown_table(rng, rng.randint(1, 3)), 

1867 ] 

1868 if depth < 2: 

1869 choices += [ 

1870 lambda: f"<blockquote>{_markdown_block(rng, depth + 1)}</blockquote>", 

1871 lambda: ( 

1872 f"<{(kind := rng.choice(('ul', 'ol')))}>" 

1873 + "".join( 

1874 f"<li>{inline() if rng.random() < 0.7 else _markdown_block(rng, depth + 1)}</li>" 

1875 for _ in range(rng.randint(1, 3)) 

1876 ) 

1877 + f"</{kind}>" 

1878 ), 

1879 ] 

1880 return rng.choice(choices)() 

1881 

1882 

1883def _markdown_table(rng: random.Random, columns: int) -> str: 

1884 head = "".join(f"<th>{_markdown_inline(rng, 0)}</th>" for _ in range(columns)) 

1885 body = "".join(f"<td>{_markdown_inline(rng, 0)}</td>" for _ in range(columns)) 

1886 return f"<table><tr>{head}</tr><tr>{body}</tr></table>" 

1887 

1888 

1889def _markdown_inline(rng: random.Random, depth: int) -> str: 

1890 out = [] 

1891 for _ in range(rng.randint(1, 4)): 

1892 draw = rng.random() 

1893 if draw < 0.5 or depth >= 2: 

1894 out.append(rng.choice(_MARKDOWN_TEXT)) 

1895 elif draw < 0.75: 

1896 tag = rng.choice(("em", "strong", "del", "b", "i")) 

1897 out.append(f"<{tag}>{_markdown_inline(rng, depth + 1)}</{tag}>") 

1898 elif draw < 0.85: 

1899 out.append(f"<code>{rng.choice(_MARKDOWN_TEXT)}</code>") 

1900 elif draw < 0.93: 

1901 out.append( 

1902 f'<a href="{rng.choice(("x", "a b", "(", "<", "http://e.x/?a=1&amp;b"))}">{rng.choice(_MARKDOWN_TEXT)}</a>' 

1903 ) 

1904 elif draw < 0.97: 

1905 out.append(f'<img src="i" alt="{rng.choice(_MARKDOWN_TEXT)}">') 

1906 else: 

1907 out.append("<br>") 

1908 return "".join(out) 

1909 

1910 

1911_CSS_TOKENS: Final = ( 

1912 "{", "}", ";", ":", "!important", "/*x*/", "/*!b*/", "@media screen{", "@supports (display:grid){", "url(a b)", 

1913 'url("x")', '"s\\"t"', "'q'", "\\", "\\61", "#fff", "0px", "1e3", "+.5", "-0", "calc(1px + 2px)", "var(--x)", 

1914 "--x:", "a,b", ">", "+", "~", "*", "[x=y]", ":not(", ")", "::before", "@import", "@charset", "\n", " ", "%", 

1915) # fmt: skip 

1916_SELECTOR_TAGS: Final = ("div", "p", "span", "a", "b", "i", "li", "ul", "section", "em") 

1917_PSEUDOS: Final = ( 

1918 ":first-child", ":last-child", ":only-child", ":nth-child(2n+1)", ":nth-child(odd)", ":nth-child(even)", 

1919 ":nth-last-child(-n+3)", ":nth-of-type(2)", ":first-of-type", ":last-of-type", ":empty", ":root", ":link", 

1920 ":any-link", ":checked", ":disabled", ":enabled", ":lang(en)", ":nth-child(+0n+1)", ":nth-child(1 of .a)", 

1921 ":only-of-type", ":defined", ":required", ":optional", ":read-only", ":read-write", ":placeholder-shown", 

1922) # fmt: skip 

1923 

1924 

1925def _selector(rng: random.Random, depth: int = 0) -> str: 

1926 """Draw a selector list over the generators' tag, class, id and attribute vocabulary.""" 

1927 return ", ".join(_complex_selector(rng, depth) for _ in range(1 if rng.random() < 0.8 else rng.randint(2, 3))) 

1928 

1929 

1930def _complex_selector(rng: random.Random, depth: int) -> str: 

1931 parts = [_compound(rng, depth)] 

1932 for _ in range(rng.choice((0, 0, 1, 1, 2, 3))): 

1933 parts += [rng.choice((" ", " > ", ">", " + ", "~", " ")), _compound(rng, depth)] 

1934 return "".join(parts) 

1935 

1936 

1937def _compound(rng: random.Random, depth: int) -> str: 

1938 out = rng.choice(("", "", "*", *_SELECTOR_TAGS, "DIV", "P")) 

1939 for _ in range(rng.choice((0, 1, 1, 2, 3)) if out else rng.randint(1, 3)): 

1940 draw = rng.random() 

1941 if draw < 0.25: 

1942 out += "." + rng.choice(("a", "b", "c", "\\61", "A")) 

1943 elif draw < 0.35: 

1944 out += "#" + rng.choice(_IDS) 

1945 elif draw < 0.6: 

1946 name = rng.choice(("title", "data-x", "lang", "class", "id", "href", "TITLE")) 

1947 operator = rng.choice(("", "=", "~=", "|=", "^=", "$=", "*=")) 

1948 value = rng.choice(("a", '"b c"', "'x'", '""', "e", "A", "en")) if operator else "" 

1949 flag = rng.choice(("", "", " i", " s", " I")) if operator else "" 

1950 out += f"[{name}{operator}{value}{flag}]" 

1951 elif draw < 0.85 or depth >= 2: 

1952 out += rng.choice(_PSEUDOS) 

1953 else: 

1954 function = rng.choice((":not", ":is", ":where", ":has", ":has", ":not")) 

1955 argument = _selector(rng, depth + 1) 

1956 out += f"{function}({rng.choice(('> ', '+ ', '~ ', '')) if function == ':has' else ''}{argument})" 

1957 return out 

1958 

1959 

1960def _vocabulary_tree(rng: random.Random, budget: int = 25) -> str: 

1961 """Markup whose tags, classes, ids and attributes are the ones the selector and XPath generators draw from.""" 

1962 out: list[str] = [] 

1963 stack: list[str] = [] 

1964 for _ in range(rng.randint(3, budget)): 

1965 if (draw := rng.random()) < 0.5: 

1966 tag = rng.choice(_SELECTOR_TAGS) 

1967 attrs = "".join( 

1968 f' {name}="{rng.choice(values)}"' 

1969 for name, values in ( 

1970 ("class", _CLASSES), 

1971 ("id", _IDS), 

1972 ("title", ("a", "b c", "A", "x", "")), 

1973 ("lang", ("en", "en-US", "fr")), 

1974 ("data-x", ("e", "a", "")), 

1975 ("href", ("#", "x")), 

1976 ) 

1977 if rng.random() < 0.25 

1978 ) 

1979 out.append(f"<{tag}{attrs}>") 

1980 stack.append(tag) 

1981 elif draw < 0.75 and stack: 

1982 out.append(f"</{stack.pop()}>") 

1983 else: 

1984 out.append(rng.choice(("x", "hello", " ", "", "<!--c-->", "y z"))) 

1985 return "".join(out) 

1986 

1987 

1988_LENGTHS: Final = (*_VALUE_SPELLINGS["0"], *_VALUE_SPELLINGS["1.5px"], *_VALUE_SPELLINGS["10px"], "2em", "5%", "auto") 

1989_COLORS: Final = ( 

1990 *_VALUE_SPELLINGS["red"], *_VALUE_SPELLINGS["white"], *_VALUE_SPELLINGS["black"], *_VALUE_SPELLINGS["navy"], 

1991 *_VALUE_SPELLINGS["transparent"], "currentcolor", "currentColor", "inherit", 

1992) # fmt: skip 

1993_SEMANTIC_VALUES: Final[dict[str, tuple[str, ...]]] = { 

1994 "color": _COLORS, 

1995 "background-color": _COLORS, 

1996 "border-top-color": _COLORS, 

1997 "outline-color": _COLORS, 

1998 "margin-top": _LENGTHS, 

1999 "margin-right": _LENGTHS, 

2000 "margin-bottom": _LENGTHS, 

2001 "margin-left": _LENGTHS, 

2002 "padding-top": _LENGTHS, 

2003 "padding-left": _LENGTHS, 

2004 "width": _LENGTHS, 

2005 "height": _LENGTHS, 

2006 "top": _LENGTHS, 

2007 "text-indent": _LENGTHS, 

2008 "letter-spacing": (*_LENGTHS, "normal"), 

2009 "font-weight": ("bold", "700", "normal", "400", "bolder", "lighter", "100", "900"), 

2010 "opacity": ("1", "+1", "01", "0.5", ".5", "0.50", "50%", "0", "0.0", "inherit"), 

2011 "z-index": ("1", "+1", "01", "auto", "-1", "2", "1.0"), 

2012 "display": ("block", "inline", "none", "flex", "BLOCK", "initial"), 

2013 "text-align": ("left", "center", "right", "start", "justify"), 

2014 "visibility": ("visible", "hidden", "collapse", "unset"), 

2015 "border-top-style": ("solid", "dashed", "none"), 

2016 "line-height": ("1.5", "normal", "10px", "150%", "1.50"), 

2017 "font-size": ("10px", "1e1px", "medium", "small", "1.5em", "0"), 

2018 "margin": (), 

2019 "padding": (), 

2020 "border": (), 

2021 "border-color": (), 

2022 "border-width": (), 

2023 "border-style": (), 

2024 "background": (), 

2025 "outline": (), 

2026} 

2027 

2028 

2029def _declaration(rng: random.Random) -> str: 

2030 name = rng.choice(list(_SEMANTIC_VALUES)) 

2031 if values := _SEMANTIC_VALUES[name]: 

2032 value = rng.choice(values) 

2033 elif name in {"margin", "padding", "border-width"}: 

2034 value = " ".join(rng.choice(_LENGTHS) for _ in range(rng.randint(1, 4))) 

2035 elif name == "border-color": 

2036 value = " ".join(rng.choice(_COLORS[:-1]) for _ in range(rng.randint(1, 4))) 

2037 elif name == "border-style": 

2038 value = " ".join(rng.choice(("solid", "dashed", "none")) for _ in range(rng.randint(1, 4))) 

2039 elif name == "background": 

2040 value = rng.choice(_COLORS[:-1]) 

2041 else: 

2042 value = f"{rng.choice(_LENGTHS[:-1])} {rng.choice(('solid', 'dashed'))} {rng.choice(_COLORS[:-1])}" 

2043 return f"{name}:{value}{' !important' if rng.random() < 0.15 else ''}" 

2044 

2045 

2046def _stylesheet(rng: random.Random) -> str: 

2047 """Draw rules over a small selector and declaration pool, so the minifier finds rules and longhands to merge.""" 

2048 selectors = [_selector(rng) for _ in range(rng.randint(1, 5))] 

2049 rules = [] 

2050 for _ in range(rng.randint(1, 8)): 

2051 body = ";".join(_declaration(rng) for _ in range(rng.randint(1, 5))) 

2052 if rng.random() < 0.2: 

2053 side = rng.choice(("margin", "padding")) 

2054 body += "".join(f";{side}-{edge}:{rng.choice(_LENGTHS)}" for edge in ("top", "right", "bottom", "left")) 

2055 rule = f"{rng.choice(selectors)}{{{body}}}" 

2056 rules.append(f"@media all{{{rule}}}" if rng.random() < 0.1 else rule) 

2057 return rng.choice(("", "\n", " /*c*/ ")).join(rules) 

2058 

2059 

2060def _generate_css(rng: random.Random) -> str: 

2061 if rng.random() < 0.25: 

2062 return css_generate(rng).data.decode("utf-8") 

2063 if rng.random() < 0.5: 

2064 return _mutate(rng, rng.choice(_corpus().css), _CSS_TOKENS) 

2065 return _mutate(rng, _stylesheet(rng), _CSS_TOKENS) if rng.random() < 0.3 else _stylesheet(rng) 

2066 

2067 

2068def _generate_css_fixpoint(rng: random.Random) -> str: 

2069 source = _generate_css(rng) 

2070 while not _css_valid_source(source): 

2071 source = _stylesheet(rng) 

2072 return source 

2073 

2074 

2075def _seeds_css_fixpoint() -> list[str]: 

2076 return [source for source in _seeds_css() if _css_valid_source(source)] 

2077 

2078 

2079def _generate_style(rng: random.Random) -> str: 

2080 body = ";".join(_declaration(rng) for _ in range(rng.randint(1, 6))) 

2081 return _mutate(rng, body, _CSS_TOKENS) if rng.random() < 0.6 else body 

2082 

2083 

2084def _generate_semantic(rng: random.Random) -> str: 

2085 inline = "" if rng.random() < 0.7 else f' style="{_declaration(rng)}"' 

2086 return f"{_root(rng)}<style>{_stylesheet(rng)}</style><div{inline}>{_vocabulary_tree(rng)}</div>" 

2087 

2088 

2089def _root(rng: random.Random) -> str: 

2090 """ 

2091 Open a no-quirks document whose root element sometimes carries attributes, so root-only matches show. 

2092 

2093 Quirks mode matches class and id selectors case-insensitively (Selectors 4 §6.6, DOM §4.2.6.1), which a selector 

2094 translated to XPath cannot know, so every generated document declares the standard mode. 

2095 """ 

2096 attrs = "".join( 

2097 f' {name}="{value}"' for name, value in (("lang", "en"), ("class", "a"), ("title", "x")) if rng.random() < 0.3 

2098 ) 

2099 return f"<!DOCTYPE html><html{attrs}>" 

2100 

2101 

2102def _generate_xpath_case(rng: random.Random) -> str: 

2103 return json.dumps({"html": _root(rng) + _vocabulary_tree(rng), "xpath": _xpath(rng)}) 

2104 

2105 

2106def _generate_selector_case(rng: random.Random) -> str: 

2107 return json.dumps({"html": _root(rng) + _vocabulary_tree(rng), "css": _selector(rng)}) 

2108 

2109 

2110_AXES: Final = ( 

2111 "child", "descendant", "descendant-or-self", "parent", "ancestor", "ancestor-or-self", "following-sibling", 

2112 "preceding-sibling", "following", "preceding", "self", "attribute", 

2113) # fmt: skip 

2114_NODE_TESTS: Final = ("*", "node()", "text()", "comment()", "processing-instruction()", *_SELECTOR_TAGS) 

2115_PREDICATES: Final = ( 

2116 "1", "2", "last()", "last()-1", "position()<3", "position()=last()", "@id", "@class='a'", "@title", "not(@id)", 

2117 "name()='p'", "count(*)>1", "contains(.,'x')", "starts-with(@class,'a')", "string-length()>2", "text()='x'", 

2118 "*", "b", "0", "true()", ".//a", "@*", "lang('en')", "normalize-space()", "-1", "1.5", "@data-x='e'", 

2119) # fmt: skip 

2120 

2121 

2122def _xpath(rng: random.Random) -> str: 

2123 """Draw an XPath 1.0 expression: a location path, a union, or a function or arithmetic over paths.""" 

2124 path = _path(rng) 

2125 return rng.choice(( 

2126 path, 

2127 path, 

2128 path, 

2129 f"{path} | {_path(rng)}", 

2130 f"count({path})", 

2131 f"string({path})", 

2132 f"sum({path})", 

2133 f"boolean({path})", 

2134 f"name({path})", 

2135 f"({path})[{rng.choice(_PREDICATES)}]", 

2136 f"count({path}) * 2 - 1 div 0", 

2137 f"concat({path}, '-', {_path(rng)})", 

2138 f"normalize-space({path})", 

2139 f"number({path}) mod 3", 

2140 f"{path} = {_path(rng)}", 

2141 f"{path} != 'x'", 

2142 "0 div 0", 

2143 "-0", 

2144 )) 

2145 

2146 

2147def _path(rng: random.Random) -> str: 

2148 steps = [] 

2149 for _ in range(rng.randint(1, 3)): 

2150 if (draw := rng.random()) < 0.15: 

2151 step = rng.choice(("..", ".", "@*", "@id", "@class")) 

2152 else: 

2153 axis = rng.choice(_AXES) + "::" if draw < 0.55 else "" 

2154 step = axis + rng.choice(_NODE_TESTS) 

2155 steps.append(step + "".join(f"[{rng.choice(_PREDICATES)}]" for _ in range(rng.choice((0, 0, 1, 2))))) 

2156 separators = [rng.choice(("/", "//")) for _ in steps] 

2157 return rng.choice(("/", "//", "", "")) + "".join( 

2158 step if index == 0 else separators[index] + step for index, step in enumerate(steps) 

2159 ) 

2160 

2161 

2162_JS_FREE: Final = ("console", "window", "Math", "JSON", "undefined", "NaN", "Infinity", "globalThis", "zz") 

2163_JS_BOUND: Final = ("a", "b", "c", "foo", "bar", "$", "_x", "\\u0061b", "of", "async", "yield", "let") 

2164_JS_STRINGS: Final = ( 

2165 "'a'", '"b"', "'it\\'s'", '"\\x41"', "'\\u{1F600}'", '"\\n"', "''", '"use strict"', "'\\0'", '"q\\\r\nr"', 

2166 "'</script>'", '"\\u2028"', "'a\"b'", '"1"', "'if'", 

2167) # fmt: skip 

2168_JS_NUMBERS: Final = ( 

2169 "0", 

2170 "1", 

2171 "1.50", 

2172 ".5", 

2173 "5.", 

2174 "1e3", 

2175 "1E-7", 

2176 "0x1F", 

2177 "0o17", 

2178 "0b101", 

2179 "1_000", 

2180 "123n", 

2181 "0.1", 

2182 "-0", 

2183) 

2184_JS_PROPERTIES: Final = ("a", "b", "if", "class", "length", "constructor", "1", "'q'", '"r s"', "0x10", "[c]", "1.5") 

2185 

2186 

2187@dataclass 

2188class _Script: 

2189 """ 

2190 The state one generated script threads through: a source of fresh names and whether a ``return`` is legal. 

2191 

2192 Fresh names keep ``let``/``const``/``class`` declarations from colliding, which acorn rejects as early errors; 

2193 references draw from the declared and free pools, so most of them bind (Domato's scope-threading rule). 

2194 """ 

2195 

2196 rng: random.Random 

2197 declared: list[str] = field(default_factory=lambda: list(_JS_BOUND[:7])) 

2198 counter: int = 0 

2199 

2200 def fresh(self) -> str: 

2201 self.counter += 1 

2202 name = f"v{self.counter}" 

2203 self.declared.append(name) 

2204 return name 

2205 

2206 def reference(self) -> str: 

2207 return self.rng.choice((*_JS_FREE, *self.declared)) 

2208 

2209 

2210def _program(rng: random.Random) -> str: 

2211 """Draw a script from statements and expressions chosen for printer hazards: ASI, operator spacing, keys.""" 

2212 script = _Script(rng) 

2213 return "".join(_statement(script, 0, in_function=False) for _ in range(rng.randint(1, 6))) 

2214 

2215 

2216def _end(rng: random.Random) -> str: 

2217 return rng.choice((";", ";", ";", "\n", ";\n")) 

2218 

2219 

2220def _statement(script: _Script, depth: int, *, in_function: bool) -> str: 

2221 rng = script.rng 

2222 

2223 def block(*, function: bool = in_function) -> str: 

2224 statements = "".join(_statement(script, depth + 1, in_function=function) for _ in range(rng.randint(0, 3))) 

2225 return "{" + statements + "}" 

2226 

2227 def expression() -> str: 

2228 return _expression(script, depth) 

2229 

2230 choices: list[Callable[[], str]] = [ 

2231 lambda: f"var {rng.choice(_JS_BOUND)} = {expression()}{_end(rng)}", 

2232 lambda: f"{rng.choice(('let', 'const'))} {script.fresh()} = {expression()}{_end(rng)}", 

2233 lambda: f"{expression()}{_end(rng)}", 

2234 lambda: f"{expression()}{_end(rng)}", 

2235 ] 

2236 if depth < 3: 

2237 choices += [ 

2238 lambda: f"function {script.fresh()}({_parameters(rng)}){block(function=True)}", 

2239 lambda: f"if ({expression()}) {block()} else {block()}", 

2240 lambda: f"for (let {script.fresh()} = 0; {script.reference()} < {expression()}; i++) {block()}", 

2241 lambda: f"for (const {script.fresh()} {rng.choice(('in', 'of'))} {expression()}) {block()}", 

2242 lambda: f"try {block()} catch ({script.fresh()}) {block()} finally {block()}", 

2243 lambda: f"switch ({expression()}) {{case {expression()}: {block()} default: break}}", 

2244 lambda: f"{(label := script.fresh())}: for (;;) {{ break {label} }}", 

2245 lambda: _class(script, depth), 

2246 block, 

2247 ] 

2248 if in_function: 

2249 choices.append(lambda: f"return{rng.choice((' ', chr(10)))}{expression()}{_end(rng)}") 

2250 return rng.choice(choices)() 

2251 

2252 

2253def _parameters(rng: random.Random) -> str: 

2254 return ", ".join(rng.sample(_JS_BOUND[:7], rng.randint(0, 3))) 

2255 

2256 

2257def _class(script: _Script, depth: int) -> str: 

2258 rng = script.rng 

2259 members = rng.sample( 

2260 [ 

2261 f"m() {{ return {_expression(script, depth + 1)} }}", 

2262 f"static s = {_expression(script, depth + 1)};", 

2263 "#p = 1; get g() { return this.#p }", 

2264 "'q'() {}", 

2265 "1() {}", 

2266 f"[{script.reference()}]() {{}}", 

2267 "set h(v) {}", 

2268 ], 

2269 rng.randint(1, 4), 

2270 ) 

2271 return f"class {script.fresh()} {rng.choice(('', 'extends Object '))}{{ {' '.join(members)} }}" 

2272 

2273 

2274def _expression(script: _Script, depth: int) -> str: 

2275 rng = script.rng 

2276 if depth >= 3 or rng.random() < 0.35: 

2277 return rng.choice(( 

2278 script.reference, 

2279 lambda: rng.choice(_JS_STRINGS), 

2280 lambda: rng.choice(_JS_NUMBERS), 

2281 lambda: rng.choice(("true", "false", "null", "this", "/a+/g", "/[/]/", "`t`", "``")), 

2282 ))() 

2283 

2284 def sub() -> str: 

2285 return _expression(script, depth + 1) 

2286 

2287 def target() -> str: 

2288 return rng.choice(script.declared[:7]) 

2289 

2290 return rng.choice(( 

2291 lambda: f"{sub()}.{rng.choice(('a', 'if', 'class', 'length'))}", 

2292 lambda: f"{sub()}[{rng.choice(_JS_STRINGS + _JS_NUMBERS)}]", 

2293 lambda: f"{sub()}?.{rng.choice(('a', 'b'))}", 

2294 lambda: f"{sub()}?.[{sub()}]", 

2295 lambda: f"{sub()}({', '.join(sub() for _ in range(rng.randint(0, 2)))})", 

2296 lambda: f"new {rng.choice(_JS_FREE[:3])}({sub()})", 

2297 lambda: f"({rng.choice(('typeof ', 'void ', '!', '-', '+', '~'))}{sub()})", 

2298 lambda: f"delete {sub()}.a", 

2299 lambda: f"({sub()} {rng.choice(_JS_BINARY)} {sub()})", 

2300 lambda: f"{target()} {rng.choice(('+ +', '- -', '+ ++', '- --', '/ /x/ /'))}{target()}", 

2301 lambda: f"({sub()} ? {sub()} : {sub()})", 

2302 lambda: f"(({', '.join(rng.sample(_JS_BOUND[:5], rng.randint(0, 2)))}) => {sub()})", 

2303 lambda: f"({{{rng.choice(_JS_PROPERTIES)}: {sub()}, {target()}, m() {{ return {sub()} }}, ...{sub()}}})", 

2304 lambda: f"[{sub()}, , ...{sub()}]", 

2305 lambda: f"({target()} {rng.choice(('=', '+=', '??=', '||=', '**='))} {sub()})", 

2306 lambda: f"`a${{{sub()}}}b${{{sub()}}}`", 

2307 lambda: f"{rng.choice(_JS_FREE[:3])}`x\\x41${{{sub()}}}`", 

2308 lambda: f"(function {rng.choice(('', 'f'))}() {{ return {sub()} }})()", 

2309 lambda: f"{rng.choice(_JS_NUMBERS[:6])} .toString()", 

2310 lambda: f"([{target()}, {target()}] = {sub()})", 

2311 lambda: f"({{a: {target()}, b = 1}} = {sub()})", 

2312 lambda: f"{target()}++", 

2313 ))() 

2314 

2315 

2316_JS_BINARY: Final = ( 

2317 "+", "-", "*", "/", "%", "**", "==", "===", "!=", "<", ">=", "&&", "||", "??", "&", "|", "^", "<<", ">>>", "in", 

2318 "instanceof", ",", 

2319) # fmt: skip 

2320 

2321 

2322_JS_TOKENS: Final = ("\n", ";", "(", ")", "{", "}", ",", "+", "-", "/", "'", "`", "${", "in ", " of ", "=>", ".", "?.") 

2323 

2324 

2325def _generate_js(rng: random.Random) -> str: 

2326 if rng.random() < 0.25: 

2327 return _mutate(rng, rng.choice(_corpus().js), _JS_TOKENS) 

2328 return _program(rng) 

2329 

2330 

2331def _generate_url(rng: random.Random) -> str: 

2332 host = rng.choice(("Example.COM", "bücher.example", "127.0.0.1", "[::1]", "sub.example.org")) 

2333 path = "/".join(rng.choices(("a", "..", ".", "%2e", "café", "a b", "%7e", ""), k=rng.randint(1, 6))) 

2334 query = rng.choice(("", "?b=2&a=1", "?utm_source=x&id=2", "?q=a+b", "?q=%E2%9C%93")) 

2335 fragment = rng.choice(("", "#frag", "#a b")) 

2336 url = f"{rng.choice(('http', 'HTTPS'))}://{host}{rng.choice(('', ':80', ':443', ':8080'))}/{path}{query}{fragment}" 

2337 return rng.choice((url, f" <{url}> ", url.replace("&", "&amp;"))) 

2338 

2339 

2340# RFC 3986 section 5.4 supplies independent targets; safe HTTP leaves avoid scheme-specific edge policies. 

2341_RESOLUTION_BASE: Final = "http://example.test/b/c/d;p?q" 

2342_RESOLUTION_CASES: Final[dict[str, tuple[str, str]]] = { 

2343 "child": ("{leaf}", "http://example.test/b/c/{leaf}"), 

2344 "current": ("./{leaf}", "http://example.test/b/c/{leaf}"), 

2345 "parent": ("../{leaf}", "http://example.test/b/{leaf}"), 

2346 "grandparent": ("../../{leaf}", "http://example.test/{leaf}"), 

2347 "root": ("/{leaf}", "http://example.test/{leaf}"), 

2348 "query": ("?{leaf}", "http://example.test/b/c/d;p?{leaf}"), 

2349 "fragment": ("#{leaf}", "http://example.test/b/c/d;p?q#{leaf}"), 

2350 "directory": ("{leaf}/", "http://example.test/b/c/{leaf}/"), 

2351} 

2352 

2353 

2354def _generate_resolution(rng: random.Random) -> str: 

2355 return f"{rng.choice(tuple(_RESOLUTION_CASES))}:g{rng.randrange(10000)}" 

2356 

2357 

2358def _resolution_controls() -> dict[str, bool]: 

2359 return { 

2360 "unchanged relative link": resolve_links_check("parent:g", lambda _node, _base: None) is not None, 

2361 "stable wrong target": resolve_links_check("parent:g", _resolve_wrong_target) is not None, 

2362 "changed repeat": resolve_links_check("parent:g", _resolve_changed_repeat) is not None, 

2363 } 

2364 

2365 

2366def _resolve_wrong_target(node: Node, _base: str) -> None: 

2367 node.select("a")[0].attrs["href"] = "http://wrong.example/" 

2368 

2369 

2370def _resolve_changed_repeat(node: Node, base: str) -> None: 

2371 if node.links()[0].url.startswith("http:"): 

2372 node.select("a")[0].attrs["href"] = node.links()[0].url + "a" 

2373 else: 

2374 node.resolve_links(base) 

2375 

2376 

2377def _generate_encoding(rng: random.Random) -> str: 

2378 variant: Final = rng.choice(tuple(_ENCODING_FORMATS)) 

2379 alphabet: Final = "abc 123café€" if _ENCODING_FORMATS[variant][0] == "cp1252" else "abc 123café€中文𐐀" 

2380 return f"{variant}\n{''.join(rng.choices(alphabet, k=rng.randint(0, 64)))}" 

2381 

2382 

2383def _encoding_stream_controls() -> dict[str, bool]: 

2384 missing: Final = EncodingMatch(None, 0.0, None) 

2385 return { 

2386 "wrong one-shot label": encoding_stream_check("utf-8-bom\ncafé", lambda _data: missing) is not None, 

2387 "wrong chunked label": encoding_stream_check("utf-8-bom\ncafé", stream=lambda _data, _width: missing) 

2388 is not None, 

2389 } 

2390 

2391 

2392def _encoding_decode_controls() -> dict[str, bool]: 

2393 return { 

2394 "wrong codec text": encoding_decode_check("utf-8-meta\ncafé", decode=lambda _data, _codec: "") is not None, 

2395 "wrong parsed text": encoding_decode_check("utf-8-meta\ncafé", parse_text=lambda _data: "") is not None, 

2396 "wrong detected label": encoding_decode_check( 

2397 "utf-8-meta\ncafé", detect_bytes=lambda _data: EncodingMatch(None, 0.0, None) 

2398 ) 

2399 is not None, 

2400 } 

2401 

2402 

2403def _encoding_seeds() -> list[str]: 

2404 return ["empty\n", *(f"{variant}\ncafé €" for variant in _ENCODING_FORMATS), *_generated(_generate_encoding, 150)()] 

2405 

2406 

2407def _generate_idna_host(rng: random.Random) -> str: 

2408 return f"{rng.choice(tuple(_IDNA_LABELS))}\n{''.join(rng.choices('abc012', k=rng.randint(0, 8)))}" 

2409 

2410 

2411def _idna_host_seeds() -> list[str]: 

2412 return [*(f"{variant}\n" for variant in _IDNA_LABELS), *_generated(_generate_idna_host, 150)()] 

2413 

2414 

2415def _idna_host_controls() -> dict[str, bool]: 

2416 return { 

2417 "unchanged Unicode host": idna_host_check("umlaut\n", lambda text: text) is not None, 

2418 "wrong mapping": idna_host_check("umlaut\n", lambda _text: "https://wrong.example/") is not None, 

2419 "non-NFC label": idna_host_check("umlaut\n", lambda _text: "https://xn--u-ccb.example/") is not None, 

2420 "changed canonical output": idna_host_check( 

2421 "umlaut\n", lambda text: normalize_url(text) if not text.isascii() else text + "a" 

2422 ) 

2423 is not None, 

2424 } 

2425 

2426 

2427def _normalize_url_controls() -> dict[str, bool]: 

2428 return { 

2429 "growing output": normalize_url_check("https://example.org/", lambda text: text + "a") is not None, 

2430 "rejects normalized output": normalize_url_check("raw", _reject_url_output) is not None, 

2431 } 

2432 

2433 

2434def _url_reparse_controls() -> dict[str, bool]: 

2435 return { 

2436 "stable uppercased scheme": url_reparse_check("raw", lambda _text: "HTTPS://example.test/") is not None, 

2437 "changes after recomposition": url_reparse_check( 

2438 "raw", lambda text: "https://example.test/" if text == "raw" else "https://changed.test/" 

2439 ) 

2440 is not None, 

2441 "rejects recomposed output": url_reparse_check("raw", _reject_url_output) is not None, 

2442 } 

2443 

2444 

2445def _reject_url_output(text: str) -> str: 

2446 if text == "raw": 

2447 return "https://example.org/" 

2448 msg = "rejected" 

2449 raise ValueError(msg) 

2450 

2451 

2452def _clean_url_controls() -> dict[str, bool]: 

2453 return { 

2454 "growing output": clean_url_check("https://example.org/", lambda text: text + "a") is not None, 

2455 "drops cleaned output": clean_url_check("raw", lambda text: "https://example.org/" if text == "raw" else None) 

2456 is not None, 

2457 "rejects cleaned output": clean_url_check("raw", _reject_url_output) is not None, 

2458 } 

2459 

2460 

2461def _seeds_url() -> list[str]: 

2462 # WPT supplies inputs: crawl cleaning differs from WHATWG serialization. 

2463 wpt = _require(_ROOT / "tools/fuzz-data/wpt/url/resources/urltestdata.json") 

2464 return list( 

2465 dict.fromkeys([ 

2466 *(row["input"] for row in json.loads(wpt.read_text(encoding="utf-8")) if isinstance(row, dict)), 

2467 *_texts((_ROOT / "tools/fuzz/corpus/url").iterdir()), 

2468 *_generated(_generate_url, 500)(), 

2469 ]) 

2470 ) 

2471 

2472 

2473def _html_controls() -> dict[str, bool]: 

2474 return { 

2475 "unescaped text": html_check("<p>&lt;b&gt;x</p>", lambda node: _unescape(node.serialize())) is not None, 

2476 "text after an end tag": html_check("<b>x</b>y", lambda node: node.serialize().replace("</b>", "</b> ")) 

2477 is not None, 

2478 } 

2479 

2480 

2481def _unescape(text: str) -> str: 

2482 return text.replace("&lt;", "<").replace("&gt;", ">") 

2483 

2484 

2485def _xml_controls() -> dict[str, bool]: 

2486 def xml(node: Node) -> str: 

2487 return node.serialize(Html(xml=True)) 

2488 

2489 return { 

2490 "unescaped ampersand": xml_check("<p>a&amp;b</p>", lambda node: xml(node).replace("&amp;", "&")) is not None, 

2491 "dropped attribute": xml_check("<p a=1>x</p>", lambda node: xml(node).replace(' a="1"', "")) is not None, 

2492 } 

2493 

2494 

2495def _css_controls() -> dict[str, bool]: 

2496 return { 

2497 "growing output": _css_fixpoint("a{color:red}", lambda css: minify_css(css) + "/*!x*/") is not None, 

2498 "late non-numeric convergence": fixpoint_check("aa", lambda text: text.replace("a", "b", 1), numeric=True) 

2499 is not None, 

2500 } 

2501 

2502 

2503def _js_controls() -> dict[str, bool]: 

2504 return { 

2505 "growing output": fixpoint_check("x", lambda source: minify_js(source, _JS_OFF) + "\n0", numeric=True) 

2506 is not None 

2507 } 

2508 

2509 

2510def _style_controls() -> dict[str, bool]: 

2511 return { 

2512 "growing output": style_check("a:b", lambda text: StyleDeclaration.parse(text + ";z:1")) is not None, 

2513 "changed value": style_check("a:b", lambda text: StyleDeclaration.parse(text.replace(": ", ":x", 1))) 

2514 is not None, 

2515 } 

2516 

2517 

2518def _markdown_controls() -> dict[str, bool]: 

2519 return {"unescaped text": markdown_check("<p>*a*</p>", lambda node: node.text) is not None} 

2520 

2521 

2522def _js_names_controls() -> dict[str, bool]: 

2523 def broken(edit: Callable[[str], str]) -> Callable[[str, JSMinify], str]: 

2524 return lambda source, options: edit(minify_js(source, options)) 

2525 

2526 return { 

2527 "invented free identifier": js_names_check("x", _JS_NAMES, broken(lambda out: out + ";zz")) is not None, 

2528 "lost string": js_names_check("f('q')", _JS_NAMES, broken(lambda out: re.sub(r"['\"]q['\"]", "0", out))) 

2529 is not None, 

2530 "captured free identifier": js_names_check( 

2531 "function f(a){return b}", _JS_NAMES, lambda _source, _options: "function f(b){return b}" 

2532 ) 

2533 is not None, 

2534 "output not JavaScript": js_names_check("x", _JS_NAMES, broken(lambda out: out + "(")) is not None, 

2535 } 

2536 

2537 

2538def _css_semantics_controls() -> dict[str, bool]: 

2539 return { 

2540 "changed value": css_semantics_check( 

2541 "<style>p{color:red}</style><p>x", lambda css: minify_css(css).replace("red", "#00f") 

2542 ) 

2543 is not None, 

2544 "rewritten selector": css_semantics_check( 

2545 "<style>div>b{x-unknown:1}</style><div><p><b>x</b></p></div>", lambda css: minify_css(css).replace(">", " ") 

2546 ) 

2547 is not None, 

2548 } 

2549 

2550 

2551def _xpath_controls() -> dict[str, bool]: 

2552 case = json.dumps({"html": "<p></p><p></p>", "xpath": "//p"}) 

2553 return { 

2554 "xpath_iter drops an item": xpath_entry_check( 

2555 case, iterate=lambda node, expression: list(node.xpath_iter(expression))[1:] 

2556 ) 

2557 is not None, 

2558 "xpath_one returns the last": xpath_entry_check( 

2559 case, first=lambda node, expression: list(node.xpath_iter(expression))[-1] 

2560 ) 

2561 is not None, 

2562 } 

2563 

2564 

2565def _selector_controls() -> dict[str, bool]: 

2566 case = json.dumps({"html": "<p>x</p>", "css": "p"}) 

2567 return { 

2568 "translated to the child axis": selector_entry_check( 

2569 case, lambda selector: css_to_xpath(selector, prefix="child::") 

2570 ) 

2571 is not None 

2572 } 

2573 

2574 

2575def _span_controls() -> dict[str, bool]: 

2576 def broken(edit: Callable[[SourceLocation], SourceLocation]) -> Callable[[Element], SourceLocation | None]: 

2577 return lambda element: None if (location := element.source_location) is None else edit(location) 

2578 

2579 def one_late(location: SourceLocation) -> SourceLocation: 

2580 start = location.start_tag 

2581 return location._replace( 

2582 start_tag=start._replace(start_offset=start.start_offset + 1, start_col=start.start_col + 1) 

2583 ) 

2584 

2585 return { 

2586 "start one late": span_check("<p class=a>x</p>", broken(one_late)) is not None, 

2587 "end tag at the start tag": span_check( 

2588 "<p class=a>x</p>", broken(lambda location: location._replace(end_tag=location.start_tag)) 

2589 ) 

2590 is not None, 

2591 } 

2592 

2593 

2594def _seeds_html() -> list[str]: 

2595 return list(_corpus().html) 

2596 

2597 

2598def _seeds_css() -> list[str]: 

2599 return list(_corpus().css) 

2600 

2601 

2602def _seeds_js() -> list[str]: 

2603 return list(_corpus().js) 

2604 

2605 

2606def _seeds_style() -> list[str]: 

2607 return [body for css in _corpus().css for body in re.findall(r"\{([^{}]*)\}", css)] 

2608 

2609 

2610def _parser_bytes_check(case: str) -> str | None: 

2611 try: 

2612 return parser_bytes_check(case) 

2613 except UnsupportedParserCaseError as error: 

2614 raise OutOfScopeError from error 

2615 

2616 

2617def _xml_island(case: str) -> str | None: 

2618 try: 

2619 return xml_island_check(case) 

2620 except UnsupportedXmlIslandCaseError as error: 

2621 raise OutOfScopeError(str(error)) from error 

2622 

2623 

2624def _xml_literal(case: str) -> str | None: 

2625 try: 

2626 return xml_literal_check(case) 

2627 except UnsupportedXmlLiteralCaseError as error: 

2628 raise OutOfScopeError(str(error)) from error 

2629 

2630 

2631ORACLES: Final[dict[str, Oracle]] = { 

2632 "encoding-stream": Oracle( 

2633 encoding_stream_check, _generate_encoding, _encoding_seeds, _encoding_stream_controls, Floor(100, 0.95) 

2634 ), 

2635 "parser-bytes": Oracle( 

2636 _parser_bytes_check, parser_bytes_generate, parser_bytes_seeds, parser_bytes_controls, Floor(100, 1) 

2637 ), 

2638 "encoding-decode": Oracle( 

2639 encoding_decode_check, _generate_encoding, _encoding_seeds, _encoding_decode_controls, Floor(100, 0.95) 

2640 ), 

2641 "css-custom-tokens": Oracle( 

2642 _css_custom_check, css_custom_generate, css_custom_seeds, css_custom_controls, Floor(100, 1) 

2643 ), 

2644 "observer-sequence": Oracle( 

2645 _observer_sequence_check, 

2646 observer_sequence_generate, 

2647 observer_sequence_seeds, 

2648 observer_sequence_controls, 

2649 Floor(100, 1), 

2650 ), 

2651 "dom-program": Oracle( 

2652 _dom_program_check, dom_program_generate, dom_program_seeds, dom_program_controls, Floor(100, 1) 

2653 ), 

2654 "dom-lifecycle": Oracle( 

2655 _dom_lifecycle_check, dom_lifecycle_generate, dom_lifecycle_seeds, dom_lifecycle_controls, Floor(200, 1) 

2656 ), 

2657 "xml-island-grammar": Oracle(_xml_island, xml_island_generate, xml_island_seeds, xml_island_controls, Floor(54, 1)), 

2658 "xml-literal-grammar": Oracle( 

2659 _xml_literal, xml_literal_generate, xml_literal_seeds, xml_literal_controls, Floor(96, 1) 

2660 ), 

2661 "iterator-sequence": Oracle( 

2662 _iterator_sequence_check, 

2663 iterator_sequence_generate, 

2664 iterator_sequence_seeds, 

2665 iterator_sequence_controls, 

2666 Floor(100, 1), 

2667 ), 

2668 "normalize-url-fixpoint": Oracle( 

2669 normalize_url_check, _generate_url, _seeds_url, _normalize_url_controls, Floor(100, 0.5) 

2670 ), 

2671 "url-split-reparse": Oracle(url_reparse_check, _generate_url, _seeds_url, _url_reparse_controls, Floor(100, 0.5)), 

2672 "idna-host": Oracle(idna_host_check, _generate_idna_host, _idna_host_seeds, _idna_host_controls, Floor(100, 0.95)), 

2673 "idna-nfc": Oracle(_idna_nfc_check, idna_nfc_generate, idna_nfc_seeds, idna_nfc_controls, Floor(100, 1)), 

2674 "clean-url-fixpoint": Oracle(clean_url_check, _generate_url, _seeds_url, _clean_url_controls, Floor(100, 0.25)), 

2675 "resolve-links": Oracle( 

2676 resolve_links_check, 

2677 _generate_resolution, 

2678 _generated(_generate_resolution, 500), 

2679 _resolution_controls, 

2680 Floor(100, 1), 

2681 syntax="html", 

2682 ), 

2683 "html-fixpoint": Oracle(html_check, _generate_html, _seeds_html, _html_controls, Floor(500, 0.85), syntax="html"), 

2684 "html-table-grammar": Oracle( 

2685 _html_table_check, 

2686 html_table_generate, 

2687 html_table_seeds, 

2688 html_table_controls, 

2689 Floor(24, 1), 

2690 syntax="html", 

2691 ), 

2692 "html-sibling-grammar": Oracle( 

2693 _html_sibling_check, 

2694 html_sibling_generate, 

2695 html_sibling_seeds, 

2696 html_sibling_controls, 

2697 Floor(100, 1), 

2698 syntax="html", 

2699 ), 

2700 "html-foreign-grammar": Oracle( 

2701 _html_foreign_check, 

2702 html_foreign_generate, 

2703 html_foreign_seeds, 

2704 html_foreign_controls, 

2705 Floor(36, 1), 

2706 syntax="html", 

2707 ), 

2708 "html-list-grammar": Oracle( 

2709 _html_list_check, 

2710 html_list_generate, 

2711 html_list_seeds, 

2712 html_list_controls, 

2713 Floor(24, 1), 

2714 syntax="html", 

2715 ), 

2716 "xml-document-grammar": Oracle( 

2717 xml_document_check, xml_source_generate, xml_source_seeds, xml_source_controls, Floor(60, 1) 

2718 ), 

2719 "xml-fixpoint": Oracle(xml_check, _generate_html, _seeds_html, _xml_controls, Floor(500, 0.95)), 

2720 "css-sheet-grammar": Oracle( 

2721 _css_fixpoint, css_source_generate, css_source_seeds, _css_controls, Floor(6356, 1), syntax="css" 

2722 ), 

2723 "css-fixpoint": Oracle( 

2724 _css_valid_fixpoint, _generate_css_fixpoint, _seeds_css_fixpoint, _css_controls, Floor(500, 0.95), syntax="css" 

2725 ), 

2726 "js-fixpoint": Oracle(_js_fixpoint, _generate_js, _seeds_js, _js_controls, Floor(300, 0.6), syntax="js"), 

2727 "style-fixpoint": Oracle( 

2728 style_check, _generate_style, _seeds_style, _style_controls, Floor(300, 0.95), syntax="css" 

2729 ), 

2730 "markdown-source-grammar": Oracle( 

2731 _markdown_source_check, 

2732 _markdown_source_generate, 

2733 markdown_source_seeds, 

2734 markdown_controls, 

2735 Floor(48, 1), 

2736 ), 

2737 "markdown-html-grammar": Oracle( 

2738 _markdown_html_check, 

2739 _markdown_html_generate, 

2740 partial(markdown_source_seeds, html=True), 

2741 partial(markdown_controls, html=True), 

2742 Floor(29, 1), 

2743 syntax="html", 

2744 ), 

2745 "markdown-fixpoint": Oracle( 

2746 markdown_check, 

2747 _markdown_document, 

2748 _generated(_markdown_document, 500), 

2749 _markdown_controls, 

2750 Floor(500, 0.95), 

2751 syntax="html", 

2752 ), 

2753 "js-names": Oracle(_js_names, _generate_js, _seeds_js, _js_names_controls, Floor(300, 0.6), syntax="js"), 

2754 "css-semantics": Oracle( 

2755 css_semantics_check, 

2756 _generate_semantic, 

2757 _generated(_generate_semantic), 

2758 _css_semantics_controls, 

2759 Floor(100, 0.95), 

2760 syntax="html", 

2761 ), 

2762 "xpath-entry": Oracle( 

2763 xpath_entry_check, 

2764 _generate_xpath_case, 

2765 _generated(_generate_xpath_case), 

2766 _xpath_controls, 

2767 Floor(100, 0.95), 

2768 fields=("html", "xpath"), 

2769 ), 

2770 "selector-entry": Oracle( 

2771 selector_entry_check, 

2772 _generate_selector_case, 

2773 _generated(_generate_selector_case), 

2774 _selector_controls, 

2775 Floor(100, 0.85), 

2776 fields=("html", "css"), 

2777 ), 

2778 "spans": Oracle(span_check, _generate_html, _seeds_html, _span_controls, Floor(500, 0.95), syntax="html"), 

2779} 

2780 

2781 

2782if __name__ == "__main__": 

2783 raise SystemExit(main())