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