diff --git a/.github/workflows/linux-sdk-contracts.yml b/.github/workflows/linux-sdk-contracts.yml index a1cfe3616..c0fcff177 100644 --- a/.github/workflows/linux-sdk-contracts.yml +++ b/.github/workflows/linux-sdk-contracts.yml @@ -15,6 +15,7 @@ jobs: - name: Header scanner regressions and public dependency closure run: | python3 eng/sdk/test-header-closure.py + python3 eng/sdk/test-verify-parser-components.py python3 eng/sdk/test-qualify-macos-profile.py python3 - <<'PY' import importlib.util diff --git a/docs/validation/parser-component-size-budget-671-229.md b/docs/validation/parser-component-size-budget-671-229.md new file mode 100644 index 000000000..60e2cdf6d --- /dev/null +++ b/docs/validation/parser-component-size-budget-671-229.md @@ -0,0 +1,24 @@ +# Parser component size budget (#671 / #229) + +The CSS/selector parser archive is a reusable SDK component. It must remain +free of the runtime HTML parser while CSS and selector coverage grows. + +`eng/sdk/verify-parser-components.py` enforces three independent boundaries: + +- required CSS and selector ABI symbols must be present; +- HTML ABI symbols and `html5ever::` symbols must be absent; +- the focused archive must be no more than 90% of the full parser archive and + no more than 18,000,000 bytes. + +The ratio detects a collapsed component split. The absolute limit detects +package growth even when the full parser grows at the same rate. Symbol checks +remain the semantic proof that HTML code was not retained. + +The Linux observation that triggered this update was 16,511,566 bytes for the +CSS/selector archive and 19,346,060 bytes for the full archive, a ratio of +0.853485. Both byte values and both configured limits are emitted in +`parser-components.json` for later release/package-size comparison. + +`eng/sdk/test-verify-parser-components.py` locks the passing boundaries and +independent failures for ratio, bytes, missing CSS ABI, retained HTML ABI, and +retained `html5ever` symbols. diff --git a/eng/sdk/test-verify-parser-components.py b/eng/sdk/test-verify-parser-components.py new file mode 100644 index 000000000..2fa8ff5c1 --- /dev/null +++ b/eng/sdk/test-verify-parser-components.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import unittest + + +SCRIPT = Path(__file__).with_name("verify-parser-components.py") +SPEC = importlib.util.spec_from_file_location("verify_parser_components", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class VerifyParserComponentsTests(unittest.TestCase): + @staticmethod + def full_symbols(): + return "\n".join(MODULE.HTML_SYMBOLS + MODULE.CSS_SYMBOLS) + + @staticmethod + def css_symbols(): + return "\n".join(MODULE.CSS_SYMBOLS) + + def test_accepts_both_budget_boundaries(self): + report = MODULE.verify_components( + self.full_symbols(), self.css_symbols(), 20_000_000, 18_000_000) + self.assertEqual(report["maximumCssToFullSizeRatio"], 0.90) + self.assertEqual(report["maximumCssSelectorArchiveBytes"], 18_000_000) + + def test_ratio_budget_fails_independently(self): + with self.assertRaisesRegex(RuntimeError, "size ratio"): + MODULE.verify_components( + self.full_symbols(), self.css_symbols(), 10_000_000, 9_000_001) + + def test_absolute_budget_fails_independently(self): + with self.assertRaisesRegex(RuntimeError, "archive size"): + MODULE.verify_components( + self.full_symbols(), self.css_symbols(), 25_000_000, 18_000_001) + + def test_missing_css_abi_fails(self): + with self.assertRaisesRegex(RuntimeError, "missingCss"): + MODULE.verify_components( + self.full_symbols(), self.css_symbols().replace(MODULE.CSS_SYMBOLS[0], ""), + 20_000_000, 10_000_000) + + def test_retained_html_symbols_fail(self): + for retained in (MODULE.HTML_SYMBOLS[0], "html5ever::"): + with self.subTest(retained=retained), self.assertRaisesRegex( + RuntimeError, "retainedHtml"): + MODULE.verify_components( + self.full_symbols(), self.css_symbols() + "\n" + retained, + 20_000_000, 10_000_000) + + +if __name__ == "__main__": + unittest.main() diff --git a/eng/sdk/verify-parser-components.py b/eng/sdk/verify-parser-components.py index e6269cc69..86b79faaf 100755 --- a/eng/sdk/verify-parser-components.py +++ b/eng/sdk/verify-parser-components.py @@ -17,6 +17,8 @@ "webscene_selector_parser_abi_version", "webscene_selector_parse", ) +MAXIMUM_CSS_TO_FULL_SIZE_RATIO = 0.90 +MAXIMUM_CSS_SELECTOR_ARCHIVE_BYTES = 18_000_000 def symbols(nm, archive): @@ -24,18 +26,7 @@ def symbols(nm, archive): [str(nm), "-g", "-C", str(archive)], text=True, stderr=subprocess.STDOUT) -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--nm", required=True, type=Path) - parser.add_argument("--full", required=True, type=Path) - parser.add_argument("--css", required=True, type=Path) - parser.add_argument("--output", required=True, type=Path) - args = parser.parse_args() - for path in (args.nm, args.full, args.css): - if not path.is_file(): - parser.error(f"missing input: {path}") - full = symbols(args.nm, args.full) - css = symbols(args.nm, args.css) +def verify_components(full, css, full_size, css_size): missing_full = [name for name in HTML_SYMBOLS + CSS_SYMBOLS if name not in full] missing_css = [name for name in CSS_SYMBOLS if name not in css] retained_html = [name for name in HTML_SYMBOLS if name in css] @@ -47,20 +38,40 @@ def main(): "missingCss": missing_css, "retainedHtml": retained_html, }, sort_keys=True)) - full_size = args.full.stat().st_size - css_size = args.css.stat().st_size ratio = css_size / full_size - if ratio > 0.85: + if ratio > MAXIMUM_CSS_TO_FULL_SIZE_RATIO: raise RuntimeError( - f"CSS/selector archive size ratio {ratio:.6f} exceeds 0.85 " - f"({css_size}/{full_size})") - report = { + f"CSS/selector archive size ratio {ratio:.6f} exceeds " + f"{MAXIMUM_CSS_TO_FULL_SIZE_RATIO:.2f} ({css_size}/{full_size})") + if css_size > MAXIMUM_CSS_SELECTOR_ARCHIVE_BYTES: + raise RuntimeError( + f"CSS/selector archive size {css_size} exceeds " + f"{MAXIMUM_CSS_SELECTOR_ARCHIVE_BYTES} bytes") + return { "schemaVersion": 1, "fullArchive": {"bytes": full_size, "html": True, "css": True, "selectors": True}, "cssSelectorArchive": {"bytes": css_size, "html": False, "css": True, "selectors": True}, "cssToFullSizeRatio": ratio, - "maximumCssToFullSizeRatio": 0.85, + "maximumCssToFullSizeRatio": MAXIMUM_CSS_TO_FULL_SIZE_RATIO, + "maximumCssSelectorArchiveBytes": MAXIMUM_CSS_SELECTOR_ARCHIVE_BYTES, } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--nm", required=True, type=Path) + parser.add_argument("--full", required=True, type=Path) + parser.add_argument("--css", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + for path in (args.nm, args.full, args.css): + if not path.is_file(): + parser.error(f"missing input: {path}") + full = symbols(args.nm, args.full) + css = symbols(args.nm, args.css) + full_size = args.full.stat().st_size + css_size = args.css.stat().st_size + report = verify_components(full, css, full_size, css_size) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2) + "\n") print(json.dumps(report, sort_keys=True))