fix(live): wrap shape-of-output bugs from second Bugbot review

Two more Cursor Bugbot findings on commit 11dfad81:

1. `filterByText` short-text returned the wrong sentinel value.
   When the trimmed snippet was shorter than 8 chars, the function
   returned `candidates.slice()` (all candidates). The caller then
   sees `filtered.length > 1` and fires `element_ambiguous` — exactly
   the opposite of the documented short-text fallback ("caller falls
   back to first-match," which corresponds to `filtered.length === 0`).
   So any picker event with a short textContent on a page with multiple
   matching siblings spuriously errored.
   Fix: return `[]` for short text, matching the JSDoc.

2. `endLine` in the wrap output was wrong for multi-line picked elements.
   `wrapperLines.length` counts ARRAY elements, but one element is a
   `\n`-joined multi-line string (originalIndented). The actual
   wrapper-region row count is `wrapperLines.length + (originalLines.length
   - 1)`. Reporting `endLine = startLine + wrapperLines.length` placed
   the boundary inside the wrapper for any multi-line pick, giving
   downstream agents an incorrect range.
   Fix: add the originalLines offset (matching what `insertLine` already
   does after the prior commit).

Test coverage:
- `short --text falls back to first-match instead of erroneously firing
  element_ambiguous` covers fix #1.
- `returns endLine that includes the multi-line original content offset`
  covers fix #2 by wrapping a 5-line <section> in a real HTML file and
  asserting the reported endLine points at the variants-end marker (and
  the next line is </main>, proving no rows were missed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-28 16:52:02 -07:00
co-authored by Claude Opus 4.7
parent 11dfad81da
commit 8660d3aa22
14 changed files with 221 additions and 26 deletions
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
+12 -2
View File
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
+12 -2
View File
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
+12 -2
View File
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
+12 -2
View File
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
+12 -2
View File
@@ -261,7 +261,12 @@ The agent should insert variant HTML at insertLine.`);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -480,7 +485,12 @@ function findAllElements(lines, query, tag = null) {
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
if (trimmed.length < 8) return candidates.slice();
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
+65
View File
@@ -577,6 +577,71 @@ describe('live-wrap — JSX / TSX correctness', () => {
assert.ok(!inside.includes('Hero Three'), 'did not wrap Hero Three');
});
it('short --text falls back to first-match instead of erroneously firing element_ambiguous', () => {
// Cursor Bugbot regression: filterByText returned `candidates.slice()`
// (all candidates) when the trimmed snippet was shorter than 8 chars.
// The caller treats `filtered.length > 1` as ambiguous — so a short
// textContent on a page with multiple matching siblings produced a
// spurious `element_ambiguous` error instead of just landing on the
// first match (the documented short-text fallback).
const tsx = `export default function Page() {
return (
<main>
<aside className="card"><h1 className="hero-title">Hi</h1></aside>
<aside className="card"><h1 className="hero-title">Hi</h1></aside>
</main>
);
}`;
writeFileSync(join(tmp, 'Short.tsx'), tsx);
// Picked element's textContent is 'Hi' — only 2 chars. With multiple
// matching siblings the prior bug fired element_ambiguous; the fix
// makes wrap silently land on the first match (existing behavior
// documented in filterByText's JSDoc).
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id short1 --count 3 --classes "card" --tag "aside" --text "Hi" --file "${join(tmp, 'Short.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const modified = readFileSync(join(tmp, 'Short.tsx'), 'utf-8');
assert.ok(modified.includes('data-impeccable-variants="short1"'),
'short --text should still wrap (fallback to first-match), not fail with element_ambiguous');
});
it('returns endLine that includes the multi-line original content offset', () => {
// Cursor Bugbot regression: the `endLine` field was computed as
// `startLine + wrapperLines.length`, but `wrapperLines` is an array
// where one element (originalIndented) is a `\n`-joined multi-line
// string. For multi-line picked elements, the actual wrapper region
// in the file spans (wrapperLines.length + originalLines.length - 1)
// rows. Reporting too-small endLine misled agents writing variants
// about the wrapper boundary.
const html = `<main>
<section class="multiline-target">
<h1>Multi</h1>
<p>Line</p>
<span>Element</span>
</section>
</main>`;
writeFileSync(join(tmp, 'multi.html'), html);
const result = JSON.parse(execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id ml1 --count 3 --classes "multiline-target" --tag "section" --file "${join(tmp, 'multi.html')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
));
const modified = readFileSync(join(tmp, 'multi.html'), 'utf-8');
const lines = modified.split('\n');
// endLine is 1-indexed; lines[endLine - 1] should be the wrapper's last
// line (the impeccable-variants-end marker for HTML).
assert.match(lines[result.endLine - 1], /impeccable-variants-end ml1/,
`endLine ${result.endLine} should point at the variants-end marker line. Got: ${JSON.stringify(lines[result.endLine - 1])}`);
// And the line after the reported endLine should be `</main>` — proving
// the entire wrapper was accounted for (no rows missing).
assert.match(lines[result.endLine], /<\/main>/,
`line after endLine should be </main>; got: ${JSON.stringify(lines[result.endLine])}`);
});
it('falls back to first-match when --text is not literally present in source (e.g. {title})', () => {
// textContent the browser sends is the rendered text, but the source uses
// a JSX expression. No candidate's source body contains the literal