From 57a09e17e20092d3c4616e2e4cf84fa4fe1fcaa7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 16:26:26 -0400 Subject: [PATCH] feat(lint): flag duplicate data-composition-id values Declaring data-composition-id on more than one element (commonly the tag from the quickstart template AND the root
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.) --- packages/lint/src/rules/composition.test.ts | 47 +++++++++++++++++++++ packages/lint/src/rules/composition.ts | 29 +++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 52f7892d0b..fb6b2fe483 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -190,6 +190,53 @@ describe("composition rules", () => { }); }); + describe("duplicate_composition_id", () => { + it("flags a meta tag and root div sharing the same data-composition-id", async () => { + const html = ` + + + + + +
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("does not flag a single valid composition id", async () => { + const html = ` + + +
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeUndefined(); + }); + + it("does not flag distinct composition ids in one file", async () => { + const html = ` + + +
+
+
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeUndefined(); + }); + }); + it("reports error when querySelector uses template literal variable", async () => { const html = ` diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index f34745c9ee..c8c3bd0b3d 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -108,6 +108,35 @@ function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[ } export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ + // duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries. + ({ tags }) => { + const tagsByCompositionId = new Map(); + for (const tag of tags) { + const compositionId = readAttr(tag.raw, "data-composition-id"); + if (!compositionId || compositionId.trim().length === 0) continue; + + const matchingTags = tagsByCompositionId.get(compositionId) ?? []; + matchingTags.push(tag.raw); + tagsByCompositionId.set(compositionId, matchingTags); + } + + const findings: HyperframeLintFinding[] = []; + for (const [compositionId, matchingTags] of tagsByCompositionId) { + if (matchingTags.length < 2) continue; + + findings.push({ + code: "duplicate_composition_id", + severity: "error", + message: `Composition id "${compositionId}" is used by ${matchingTags.length} elements. Each data-composition-id value must be unique within a composition file.`, + fixHint: + "Keep data-composition-id on exactly one element, the composition root. Remove it from metadata or duplicate hosts, especially a tag carrying the same data-composition-id as the root
, which causes a silent duplicate-id collision.", + snippet: truncateSnippet(matchingTags[0] ?? ""), + }); + } + + return findings; + }, + // invalid_parent_traversal_in_asset_path — catches `../` traversal in src, // href, inline-style url(), and