mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
fix(live): land valid TSX through wrap → preview → accept → carbonize
Closes #114. Three orthogonal bugs that surfaced together when live mode picked an element inside a Vite React/TSX component with sibling branches: 1. JSX wrapper insertion produced invalid TSX - Replacing a single picked JSX child with [comment, <div>, comment] yields three adjacent siblings, which oxc rejects with "Adjacent JSX elements must be wrapped in an enclosing tag." - A Fragment `<></>` solves the adjacency case but breaks `cloneElement`-using parents (Radix `asChild`, Headless UI, etc.) with "Invalid prop supplied to React.Fragment." - Fix: keep the wrapper `<div data-impeccable-variants="ID">` as the single JSX-slot child and tuck both marker comments INSIDE it. accept/discard now expands its replacement range to include the wrapper's `<div>` open/close lines via div-depth tracking. 2. carbonize produced nested template literals in TSX `<style>` - extractCss captured `{` / `` `} `` lines from the agent's existing `<style>{`…`}</style>` template, then handleAccept re-wrapped with another pair, producing `<style>{`{`@scope…`}`}</style>` which oxc rejects with "Expected `}` but found `@`". - Fix: extractCss now strips a leading `{` and trailing `` `} `` wherever they appear in the captured content (own line OR attached to the first/last CSS line), so re-wrapping always yields exactly one `{` ` … ` `}` pair. 3. Ambiguous source matching for repeated JSX branches - `findElement` returned the first substring match. Multiple `<aside className="card">` siblings all matched the same query, so wrap silently landed on the first regardless of which one the user picked. - Fix: live-wrap accepts `--text TEXT` (the picked element's textContent), collects ALL candidates via `findAllElements`, and narrows by a tag-stripped, JSX-expression-stripped substring match. Returns `element_ambiguous + candidates[]` when multiple branches match equally; falls back to first-match when source uses dynamic content (`<h1>{title}</h1>`) so existing flows aren't broken. - The fake e2e agent now forwards `event.element.textContent` to wrap, and live.md tells the agent to do the same. Test coverage: - New `vite8-react-tsx-repeated-aside` e2e fixture: three identical `<aside>` branches, picks the second card's <h1>, runs the full wrap → Go → cycle → accept → carbonize cycle on a real Vite + TSX dev server, asserts that Hero One and Hero Three survive untouched (proving wrap landed on the correct branch). - Six new unit tests across live-wrap.test.mjs and live-accept.test.mjs covering the Fragment-replacement design, both leading/trailing template-literal placements, --text disambiguation, the dynamic- content fallback, and the element_ambiguous error shape. - New `runtime.assertSourceContains` fixture hook so other regression fixtures can assert sibling-branch survivability cheaply. All 186 unit + static-fixture tests pass; all 21 live e2e fixtures (20 prior + new TSX) pass with no console errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
638af20566
commit
54d9f05ea5
@@ -74,7 +74,7 @@ Reading annotations precisely:
|
||||
### 2. Wrap the element
|
||||
|
||||
```bash
|
||||
node .github/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
|
||||
node .github/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
```
|
||||
|
||||
Flag mapping — keep them separate, don't collapse into `--query`:
|
||||
@@ -82,9 +82,12 @@ Flag mapping — keep them separate, don't collapse into `--query`:
|
||||
- `--element-id` ← `event.element.id`
|
||||
- `--classes` ← `event.element.classes` joined with commas
|
||||
- `--tag` ← `event.element.tagName`
|
||||
- `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of `<Card>`s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element.
|
||||
|
||||
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"` — read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax }`.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
@@ -181,6 +184,23 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
|
||||
|
||||
```tsx
|
||||
<style data-impeccable-css="SESSION_ID">{`
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
`}</style>
|
||||
<div data-impeccable-variant="1">
|
||||
{/* variant 1 */}
|
||||
</div>
|
||||
<div data-impeccable-variant="2" style={{ display: 'none' }}>
|
||||
{/* variant 2 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX.
|
||||
|
||||
### 7. Parameters (composition-sized, 0–4 per variant)
|
||||
|
||||
Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
@@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) {
|
||||
|
||||
const original = extractOriginal(lines, block);
|
||||
const indent = lines[block.start].match(/^(\s*)/)[1];
|
||||
const isJsx = detectCommentSyntax(targetFile).open === '{/*';
|
||||
const replaceRange = expandReplaceRange(block, lines, isJsx);
|
||||
|
||||
// De-indent the original content back to the marker's indentation level
|
||||
const restored = deindentContent(original, indent);
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, block.start),
|
||||
...lines.slice(0, replaceRange.start),
|
||||
...restored,
|
||||
...lines.slice(block.end + 1),
|
||||
...lines.slice(replaceRange.end + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
return {};
|
||||
@@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
|
||||
const indent = lines[block.start].match(/^(\s*)/)[1];
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
|
||||
// Extract the chosen variant's inner content
|
||||
const variantContent = extractVariant(lines, block, variantNum);
|
||||
@@ -149,7 +152,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const replacement = [];
|
||||
|
||||
if (cssContent) {
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
|
||||
// JSX targets need the CSS body wrapped in a template literal so that the
|
||||
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
|
||||
@@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
// need the object form, otherwise React 19 throws "Failed to set indexed
|
||||
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
|
||||
if (cssContent) {
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
|
||||
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
|
||||
replacement.push(...restored);
|
||||
@@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
replacement.push(...restored);
|
||||
}
|
||||
|
||||
const replaceRange = expandReplaceRange(block, lines, isJsx);
|
||||
const newLines = [
|
||||
...lines.slice(0, block.start),
|
||||
...lines.slice(0, replaceRange.start),
|
||||
...replacement,
|
||||
...lines.slice(block.end + 1),
|
||||
...lines.slice(replaceRange.end + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
|
||||
@@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) {
|
||||
return (start !== -1 && end !== -1) ? { start, end } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the line range to REPLACE (vs. just the marker range to extract
|
||||
* from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
|
||||
* the `<div data-impeccable-variants="ID">` outer wrapper so the picked
|
||||
* element's JSX slot keeps a single child — a Fragment `<></>` would have
|
||||
* solved the multi-sibling case but failed inside `asChild` / cloneElement
|
||||
* parents with "Invalid prop supplied to React.Fragment".
|
||||
*
|
||||
* That means the marker block is enclosed by the wrapper `<div>` opener
|
||||
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
|
||||
* walk back to the opener and forward to the closer so accept/discard
|
||||
* remove the entire scaffold, not just the inner markers.
|
||||
*
|
||||
* Marker lines themselves stay where they were so extractOriginal /
|
||||
* extractVariant / extractCss continue to walk the same range.
|
||||
*/
|
||||
function expandReplaceRange(block, lines, isJsx) {
|
||||
if (!isJsx) return { start: block.start, end: block.end };
|
||||
|
||||
let { start, end } = block;
|
||||
|
||||
// Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
|
||||
// The attr may sit on a continuation line of a multi-line opening tag, so
|
||||
// also walk to the line that actually contains `<div`.
|
||||
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
|
||||
if (/data-impeccable-variants=/.test(lines[i])) {
|
||||
let opener = i;
|
||||
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
|
||||
start = opener;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk forward to the matching `</div>` by div-depth tracking from the
|
||||
// wrapper opener. Self-closing `<div … />` doesn't contribute depth.
|
||||
const openRe = /<div\b/g;
|
||||
const selfCloseRe = /<div\b[^>]*\/\s*>/g;
|
||||
const closeRe = /<\/div\s*>/g;
|
||||
let depth = 0;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
depth += opens - selfCloses - closes;
|
||||
if (depth <= 0 && i >= end) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Join wrapper lines into a single string with `<style>` elements removed so
|
||||
* marker matching and div-depth tracking aren't confused by:
|
||||
@@ -345,7 +401,7 @@ function extractCss(lines, block, id) {
|
||||
// Same-line open + close: extract inner text.
|
||||
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
|
||||
if (sameLine) {
|
||||
const inner = sameLine[1];
|
||||
const inner = stripJsxTemplateWrap(sameLine[1]);
|
||||
return inner.length > 0 ? inner.split('\n') : null;
|
||||
}
|
||||
inStyle = true;
|
||||
@@ -362,7 +418,60 @@ function extractCss(lines, block, id) {
|
||||
}
|
||||
}
|
||||
|
||||
return content.length > 0 ? content : null;
|
||||
if (content.length === 0) return null;
|
||||
return stripJsxTemplateLines(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
|
||||
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
|
||||
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
|
||||
* or attached to the first/last CSS lines — all three are JSX-legal.
|
||||
*
|
||||
* Stripping is required because handleAccept re-wraps the CSS itself when
|
||||
* carbonizing. Without this, two consecutive accepts (or a previously-
|
||||
* accepted variants block being carbonized) would produce nested
|
||||
* `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
|
||||
*/
|
||||
function stripJsxTemplateLines(content) {
|
||||
const out = content.slice();
|
||||
|
||||
// Drop any leading blank lines so we don't miss a `{` line buried below
|
||||
// them; same for trailing.
|
||||
while (out.length > 0 && out[0].trim() === '') out.shift();
|
||||
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
|
||||
if (out.length === 0) return null;
|
||||
|
||||
// Leading `{`: own line, or attached to the first CSS line.
|
||||
const firstTrim = out[0].trimStart();
|
||||
if (firstTrim === '{`') {
|
||||
out.shift();
|
||||
} else if (firstTrim.startsWith('{`')) {
|
||||
const idx = out[0].indexOf('{`');
|
||||
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
|
||||
if (out[0].trim() === '') out.shift();
|
||||
}
|
||||
if (out.length === 0) return null;
|
||||
|
||||
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
|
||||
const lastIdx = out.length - 1;
|
||||
const lastTrim = out[lastIdx].trimEnd();
|
||||
if (lastTrim === '`}') {
|
||||
out.pop();
|
||||
} else if (lastTrim.endsWith('`}')) {
|
||||
const text = out[lastIdx];
|
||||
const idx = text.lastIndexOf('`}');
|
||||
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
|
||||
if (out[lastIdx].trim() === '') out.pop();
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function stripJsxTemplateWrap(text) {
|
||||
const lines = text.split('\n');
|
||||
const stripped = stripJsxTemplateLines(lines);
|
||||
return stripped ? stripped.join('\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,10 @@ Element identification (at least one required):
|
||||
|
||||
Optional:
|
||||
--file PATH Source file to search in (skips auto-detection)
|
||||
--text TEXT Picked element's textContent. Used to disambiguate when
|
||||
classes/tag match multiple sibling elements (e.g. a list
|
||||
of <Card>s with the same className). Pass the first ~80
|
||||
chars of event.element.textContent.
|
||||
--help Show this help message
|
||||
|
||||
Output (JSON):
|
||||
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const tag = argVal(args, '--tag');
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -115,17 +120,67 @@ 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.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
// Find the element, trying each query in priority order. When `--text` is
|
||||
// supplied, collect every candidate the queries surface and disambiguate
|
||||
// by the picked element's textContent. Without `--text`, fall back to the
|
||||
// legacy first-match behavior so unmodified callers keep working.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
|
||||
process.exit(1);
|
||||
if (text) {
|
||||
const candidates = [];
|
||||
for (const q of queries) {
|
||||
const all = findAllElements(lines, q, tag);
|
||||
for (const c of all) {
|
||||
if (!candidates.some((x) => x.startLine === c.startLine)) {
|
||||
candidates.push(c);
|
||||
}
|
||||
}
|
||||
// Once a more-specific query (ID, full className combo) yielded a unique
|
||||
// result, stop — falling through to the loose tag+single-class query
|
||||
// would readmit the siblings we just disambiguated past.
|
||||
if (candidates.length === 1) break;
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
|
||||
process.exit(1);
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
match = candidates[0];
|
||||
} else {
|
||||
const filtered = filterByText(candidates, lines, text);
|
||||
if (filtered.length === 1) {
|
||||
match = filtered[0];
|
||||
} else if (filtered.length === 0) {
|
||||
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
|
||||
// browser-side textContent doesn't appear literally in source. Fall
|
||||
// back to first-match rather than refusing — this is the same
|
||||
// behavior unmodified callers see, just preserved.
|
||||
match = candidates[0];
|
||||
} else {
|
||||
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
|
||||
// rather than pick wrong, and hand the agent the candidate locations
|
||||
// so it can disambiguate by reading the file.
|
||||
console.error(JSON.stringify({
|
||||
error: 'element_ambiguous',
|
||||
fallback: 'agent-driven',
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
candidates: filtered.map((c) => ({
|
||||
startLine: c.startLine + 1,
|
||||
endLine: c.endLine + 1,
|
||||
})),
|
||||
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
@@ -142,8 +197,29 @@ The agent should insert variant HTML at insertLine.`);
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
// JSX/TSX guard: the picked element occupies a single JSX child slot
|
||||
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
|
||||
// any other expression position). Replacing it with `comment + <div> +
|
||||
// comment` yields three adjacent siblings — invalid JSX. We can't use a
|
||||
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
|
||||
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
|
||||
// they try to pass an `id` through.
|
||||
//
|
||||
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
|
||||
// tuck both marker comments INSIDE it. accept/discard then expands its
|
||||
// replacement range to include the wrapper's `<div>` open / close lines
|
||||
// so the entire scaffold gets removed cleanly.
|
||||
const wrapperLines = isJsx ? [
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalLines.map(l => indent + ' ' + l.trimStart()).join('\n'),
|
||||
indent + ' </div>',
|
||||
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
|
||||
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
|
||||
indent + '</div>',
|
||||
] : [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
@@ -163,8 +239,14 @@ The agent should insert variant HTML at insertLine.`);
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
|
||||
// Calculate insert line (the "insert below this line" comment)
|
||||
const insertLine = startLine + 6; // 0-indexed in the new file
|
||||
// Calculate insert line (the "insert below this line" comment).
|
||||
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
|
||||
// the insert marker (HTML: start-comment + outer-div + Original-comment +
|
||||
// original-div + content + close-original-div; JSX: outer-div +
|
||||
// start-comment + Original-comment + original-div + content +
|
||||
// close-original-div). Multi-line originals push the marker by their
|
||||
// extra line count.
|
||||
const insertLine = startLine + 6 + (originalLines.length - 1);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
@@ -330,6 +412,57 @@ function findElement(lines, query, tag = null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like findElement, but returns every match. Used for ambiguity detection
|
||||
* when the agent passes --text: when the same className appears on multiple
|
||||
* sibling elements (a list of cards, repeated section variants, etc.),
|
||||
* first-match silently lands on the wrong branch. Returning all matches lets
|
||||
* the caller narrow by textContent or fail with a structured ambiguity error.
|
||||
*/
|
||||
function findAllElements(lines, query, tag = null) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].includes(query)) continue;
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
if (seen.has(openerLine)) continue; // multiple matches inside the same element
|
||||
seen.add(openerLine);
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
out.push({ startLine: openerLine, endLine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
*/
|
||||
function filterByText(candidates, lines, text) {
|
||||
const target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
|
||||
Reference in New Issue
Block a user