fix(lint): flag an unclosed tag that swallows a nested element as bogus attribute text (#3869)

A start tag missing its closing `>` before the next `<` (e.g. a bad
string-replace that leaves `<img ... <div class="hl"></div>` behind) is
parsed leniently by the browser's HTML5 tokenizer: the `<div` text is
consumed as a bogus attribute name on the still-open `img` tag, and the
intended element never becomes a real DOM node. No existing lint/check gate
catches this — they all operate on the resolved DOM, which looks
structurally valid once the browser has already dropped the element.

Adds `unclosed_tag_swallowed_element`, a new core lint rule that flags any
parsed tag whose attribute text contains a `<` outside a quoted value (a
legitimate attribute value may itself contain a raw `<`, e.g.
`data-expr="x < y"`, which htmlparser2 parses correctly and is not flagged).

PRINFRA-668

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
miga-heygen
2026-09-11 03:46:54 +00:00
committed by GitHub
co-authored by Claude Sonnet 5
parent bafc7b4e00
commit 2ef76bf97b
3 changed files with 74 additions and 0 deletions
+29
View File
@@ -942,6 +942,35 @@ describe("core rules", () => {
});
});
describe("unclosed_tag_swallowed_element", () => {
it("flags an <img> tag whose unclosed start tag swallows a nested <div> as bogus attribute text", async () => {
const html = compositionWithBodyPrefix(
`<img class="browser-img" src="a.png" <div class="hl"></div></figure>`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "unclosed_tag_swallowed_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.snippet).toContain("<img");
});
it("does not flag a normal <img> tag", async () => {
const html = compositionWithBodyPrefix(`<img class="browser-img" src="a.png" />`);
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "unclosed_tag_swallowed_element"),
).toBeUndefined();
});
it("does not flag a legitimate attribute value containing a raw <", async () => {
const html = compositionWithBodyPrefix(`<div data-expr="x < y">hi</div>`);
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "unclosed_tag_swallowed_element"),
).toBeUndefined();
});
});
describe("css_parse_error — malformed CSS is reported instead of silently swallowed", () => {
it("reports a css_parse_error finding for unparseable CSS", async () => {
const html = `<html><body>
+17
View File
@@ -10,6 +10,7 @@ import {
extractCompositionIdsFromCss,
extractTimelineRegistryKeys,
getInlineScriptSyntaxError,
hasUnquotedLessThan,
TIMELINE_REGISTRY_INIT_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
@@ -435,6 +436,22 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},
// unclosed_tag_swallowed_element
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (!hasUnquotedLessThan(tag.attrs)) continue;
findings.push({
code: "unclosed_tag_swallowed_element",
severity: "error",
message: `<${tag.name}> is missing its closing \`>\` before the next \`<\` — the following element is swallowed as bogus attribute text and never becomes a real node.`,
fixHint: "Close the previous tag's `>` before opening the next element.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// invalid_inline_script_syntax (malformed close tag)
({ source }) => {
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
+28
View File
@@ -166,6 +166,34 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op
return null;
}
/**
* Whether a tag's attribute text contains a `<` outside any quoted value.
*
* A legitimate attribute value may itself contain a raw `<` (e.g.
* `data-expr="x < y"`) — that's fine, htmlparser2 (and browsers) parse it as
* ordinary attribute text. But a `<` OUTSIDE any quotes means a following
* start tag never got its own `<`: the HTML tokenizer swallowed it as bogus
* attribute-name text on the tag currently open, and the intended element
* never becomes a real node. `<img src="a.png" <div class="hl">` is exactly
* this: `attrs` comes back as ` src="a.png" <div class="hl"` and the `.hl`
* div silently never renders.
*/
export function hasUnquotedLessThan(attrs: string): boolean {
let quote: '"' | "'" | null = null;
for (const ch of attrs) {
if (quote) {
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === "<") {
return true;
}
}
return false;
}
export function readAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");