fix(lint): consolidate lint and audit correctness (#2413)

* fix(lint): stop CSS comments in <style> from manufacturing phantom root tags

extractOpenTags scans raw source text with a flat regex that has no
concept of <style>/<script> block boundaries, so a CSS comment like
`/* <g> wrapper */` inside a <style> block reads as a real open tag.
findRootTag consumes that flat tag list and only skips tags literally
named script/style/meta/link/title, so the phantom <g> tag (not in
that skip list) wins the "first non-ignored body tag" search and gets
returned as the composition root instead of the real one that follows.

This manufactured root_missing_composition_id and root_missing_dimensions
(the phantom tag has neither) plus head_leaked_text (the leaked-text
scan slices up to the phantom tag's position, landing inside the
<style> block before its real closing tag, so the raw CSS text reads
as leaked markup) on an otherwise valid sub-composition — reported
with an exact bisected repro: a <template>-wrapped SVG sub-composition
whose <style> block comments reference an inner <g> element.

Fix: compute <style>/<script> content spans up front (reusing the
existing extractBlocks + STYLE_BLOCK_PATTERN/SCRIPT_BLOCK_PATTERN) and
skip any TAG_PATTERN match that falls inside one, before it ever
reaches findRootTag or any other extractOpenTags consumer. Same shape
as the prior fix for a leading <svg> defs block being mistaken for the
root (8ee4b7df) — this closes a sibling gap in the same function.

Test: new regression case with a <style> block containing a `/* <g> */`
comment ahead of an <svg data-composition-id> root, asserting none of
the three findings fire. Full lint package suite (318 tests) passes.

* feat(lint): flag duplicate data-composition-id values

Declaring data-composition-id on more than one element (commonly the <meta>
tag from the quickstart template AND the root <div> added to satisfy
root_missing_composition_id) is a silent collision: `compositions --json`
returns two entries for the same id (one duration:0) and inspect/snapshot
crash with "Cannot read properties of undefined (reading totalDuration)".
Lint passed clean through all of it.

New rule `duplicate_composition_id`: group elements by data-composition-id
value and error on any value shared by 2+ elements, naming the id and calling
out the meta-vs-root collision in the fixHint. 3 tests: dup fires, single id
passes, two distinct ids don't collide. (Implemented via Codex; verified
independently: 111 lint tests pass, oxfmt/oxlint clean.)

* fix(audits): avoid caption false positives

* fix(lint): ignore proxy-label tween overlaps

* fix(cli): preserve the five-percent text audit floor

* fix(lint): preserve proxy identity across lexical scopes

* fix(cli): audit only directly painted text

* fix(lint): compare live composition ids canonically

* fix(lint): preserve expanded proxy identities

* fix(cli): measure directly painted text geometry

* fix(lint): preserve first duplicate attribute value

* fix(lint): keep shared proxy identity across helpers

* fix(parsers): preserve expanded proxy identity

* fix(lint): decode composition IDs consistently
This commit is contained in:
Miguel Ángel
2026-07-14 20:13:01 -04:00
committed by GitHub
parent 37e1b26434
commit ada878fdcd
16 changed files with 759 additions and 64 deletions
+37
View File
@@ -214,6 +214,29 @@ describe("core rules", () => {
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("does not mistake a <tag>-shaped CSS comment inside <style> for the composition root", async () => {
// Regression: a CSS comment referencing an SVG tag name (e.g. `/* <g> wrapper */`)
// inside a <style> block reads as a real open tag to the flat TAG_PATTERN scan,
// manufacturing a phantom root before the real composition root and firing
// root_missing_composition_id/root_missing_dimensions/head_leaked_text on an
// otherwise valid sub-composition.
const html = `
<html><body>
<style>
/* <g> wrapper for icon groups */
.icon { fill: currentColor; }
</style>
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<g class="icon"></g>
</svg>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined();
});
it("reports error when timeline registry is missing", async () => {
const html = `
<html><body>
@@ -778,6 +801,20 @@ body {
expect(finding).toBeUndefined();
});
it("matches timeline keys against browser-decoded composition ids", async () => {
const html = `
<html><body>
<div data-composition-id="&#99;1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
});
it("accepts object-literal timeline registration and extracts its keys", async () => {
const html = `
<html><body>