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