From a4832adf2fd1242dda5af0101af7ed4b29419e07 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Apr 2026 00:19:32 -0700 Subject: [PATCH] =?UTF-8?q?fix(live-wrap):=20JSX/TSX=20correctness=20?= =?UTF-8?q?=E2=80=94=20multi-line=20tags,=20className,=20tag=20narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five related bugs that surfaced in a real Next.js App Router project (EAC) all rooted in live-wrap.mjs treating source as line-anchored HTML: 1. findElement matched on raw substring anywhere, so it landed on a className continuation line of a multi-line JSX tag whose class happened to collide with a later target. The wrong tag got wrapped (really, its attribute line got wrapped, producing broken JSX). 2. findClosingLine's opener regex required whitespace or `>` after the tag name, so a bare `` opener was unrecognised; it returned `start` silently, capturing only one line. 3. buildSearchQueries only emitted `class="..."`, missing React's `className="..."`. The full-combo query never fired in JSX, so search silently degraded to single-class substring matching. 4. Wrapper output used `style="display: contents"` unconditionally, which is invalid JSX (type error in strict setups, parser hazard in production transforms). 5. --tag was ignored during the primary class search. Ambiguous class hits inside the wrong element type weren't filtered out. ## Fixes - New OPENER_RE `/<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/` recognises tag openers at end-of-line too. - New findOpenerLine(lines, matchLine, tag): walks up to 10 lines backward to the enclosing opener when the match lands on a continuation line. Aborts the walk if it hits a different tag. - findElement now iterates all matches (not just the first), takes a tag parameter, and routes through findOpenerLine; wrapCli passes --tag through. - buildSearchQueries emits both `class="..."` and `className="..."` for multi-class queries, and both ` when a class collides with a multi-line tag elsewhere - emits JSX-safe style attribute ({{ }}) in .tsx files - finds elements via className= (React) when the exact class combo is unique there - respects --tag to reject matches inside the wrong element type - findClosingLine recognises an opener line where the tag sits at end-of-line (multi-line JSX) 31/31 in tests/live-wrap.test.mjs and 54/54 in tests/framework-fixtures.test.mjs pass. Credit: precise bug report from the other agent in the EAC session made diagnosis and test design straightforward. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .kiro/skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .pi/skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .trae/skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- .../skills/impeccable/scripts/live-wrap.mjs | 118 +++++++++---- tests/live-wrap.test.mjs | 156 ++++++++++++++++++ 13 files changed, 1164 insertions(+), 408 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-wrap.mjs b/.agents/skills/impeccable/scripts/live-wrap.mjs index 965f6a160..7c585f32d 100644 --- a/.agents/skills/impeccable/scripts/live-wrap.mjs +++ b/.agents/skills/impeccable/scripts/live-wrap.mjs @@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`); const content = fs.readFileSync(targetFile, 'utf-8'); const lines = content.split('\n'); - // Find the element, trying each query in priority order + // Find the element, trying each query in priority order. + // Pass tag hint so findElement can reject matches inside wrong element types + // and walk backward to the real opener on multi-line JSX tags. let match = null; for (const q of queries) { - match = findElement(lines, q); + match = findElement(lines, q, tag); if (match) break; } if (!match) { @@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`); const { startLine, endLine } = match; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; const indent = lines[startLine].match(/^(\s*)/)[1]; // Extract the original element const originalLines = lines.slice(startLine, endLine + 1); const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n'); + // Wrapper attributes differ by syntax. HTML allows plain string attrs; + // JSX requires object-literal style and parses string attrs as HTML (which + // either type-errors or renders a literal CSS string). + const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"'; + // Build the wrapper const wrapperLines = [ indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close, - indent + '
', + indent + '
', indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close, indent + '
', originalIndented, @@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) { queries.push('id="' + elementId + '"'); } - // 2. Full class attribute match (for elements with distinctive multi-class combos) + // 2. Full class attribute match (for elements with distinctive multi-class combos). + // Emit both class="..." (HTML) and className="..." (React/JSX) so whichever + // convention the file uses will match. if (classes) { const classList = classes.split(',').map(c => c.trim()).filter(Boolean); if (classList.length > 1) { - // Try the most distinctive class first (longest, most specific) + const joined = classList.join(' '); const sorted = [...classList].sort((a, b) => b.length - a.length); - queries.push('class="' + classList.join(' ') + '"'); // exact full match - queries.push(sorted[0]); // most distinctive single class + queries.push('class="' + joined + '"'); + queries.push('className="' + joined + '"'); + queries.push(sorted[0]); // most distinctive single class, fallback } else if (classList.length === 1) { queries.push(classList[0]); } } - // 3. Tag + class combo (e.g.,
) + // 3. Tag + class combo (e.g.,
). + // Same dual-emit for JSX compatibility. if (tag && classes) { const firstClass = classes.split(',')[0].trim(); queries.push('<' + tag + ' class="' + firstClass); + queries.push('<' + tag + ' className="' + firstClass); } // 4. Raw fallback query @@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) { } /** - * Find the element's start and end line in the file. - * The query is a class name, ID, or text snippet. - * We find the line containing the query, then find the matching closing tag. + * Regex that matches a tag opener on a line. Allows the tag name to be + * followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX + * openers (e.g. ``) are recognised. */ -function findElement(lines, query) { - // Find the line containing the query - let startLine = -1; +const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/; + +/** + * Find the element's start and end line in the file. + * + * `query` is a class name, attribute fragment (`class="..."`, `className="..."`, + * `id="..."`), or a raw text snippet. Because a query can appear on a + * continuation line of a multi-line tag (e.g. the `className="..."` row of a + * `` JSX tag), we walk backward from the match + * line to find the actual tag opener. When `tag` is provided, opener candidates + * must match that tag name. + */ +function findElement(lines, query, tag = null) { + // Iterate all matches — the first substring hit isn't always the right one. for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(query)) { - // Make sure this looks like a tag opening, not a comment or string - const line = lines[i].trim(); - if (line.startsWith('