]*\/\s*>/g;
- const closeRe = /<\/div\s*>/g;
+ // wrapper opener. Operate on JOINED text instead of per-line: a
+ // multi-line self-closing JSX `
` orphaned after accept/discard. Single regex with
+ // `[^>]*?` (which spans newlines in JS) handles either form correctly.
+ const joined = lines.slice(start).join('\n');
+ // Match either `
` (self-close, group 1 is `/`), `
`
+ // (open, group 1 is empty), or `
`.
+ const tagRe = /
]*?(\/?)>|<\/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;
+ let m;
+ while ((m = tagRe.exec(joined)) !== null) {
+ const isClose = m[0].startsWith('');
+ const isSelfClose = !isClose && m[1] === '/';
+ if (isClose) depth--;
+ else if (!isSelfClose) depth++;
+ if (depth <= 0) {
+ // m.index is offset within `joined`; convert back to a file line.
+ const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
+ const candidateEnd = start + linesBefore;
+ if (candidateEnd >= end) {
+ end = candidateEnd;
+ break;
+ }
}
}
diff --git a/source/skills/impeccable/scripts/live-accept.mjs b/source/skills/impeccable/scripts/live-accept.mjs
index b1e0b035c..f3cb1b484 100644
--- a/source/skills/impeccable/scripts/live-accept.mjs
+++ b/source/skills/impeccable/scripts/live-accept.mjs
@@ -263,20 +263,32 @@ function expandReplaceRange(block, lines, isJsx) {
}
// Walk forward to the matching `
` by div-depth tracking from the
- // wrapper opener. Self-closing `
` doesn't contribute depth.
- const openRe = /
]*\/\s*>/g;
- const closeRe = /<\/div\s*>/g;
+ // wrapper opener. Operate on JOINED text instead of per-line: a
+ // multi-line self-closing JSX `
` would
+ // fool per-line regex tracking (the `
` line never matches selfCloseRe since it needs `
` orphaned after accept/discard. Single regex with
+ // `[^>]*?` (which spans newlines in JS) handles either form correctly.
+ const joined = lines.slice(start).join('\n');
+ // Match either `
` (self-close, group 1 is `/`), `
`
+ // (open, group 1 is empty), or `
`.
+ const tagRe = /
]*?(\/?)>|<\/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;
+ let m;
+ while ((m = tagRe.exec(joined)) !== null) {
+ const isClose = m[0].startsWith('');
+ const isSelfClose = !isClose && m[1] === '/';
+ if (isClose) depth--;
+ else if (!isSelfClose) depth++;
+ if (depth <= 0) {
+ // m.index is offset within `joined`; convert back to a file line.
+ const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
+ const candidateEnd = start + linesBefore;
+ if (candidateEnd >= end) {
+ end = candidateEnd;
+ break;
+ }
}
}
diff --git a/tests/live-accept.test.mjs b/tests/live-accept.test.mjs
index 59dabc670..ea1f1d7ca 100644
--- a/tests/live-accept.test.mjs
+++ b/tests/live-accept.test.mjs
@@ -248,6 +248,60 @@ describe('live-accept — style-element edge cases', () => {
` closer must be back at 6-space indent. Got:\n${after}`);
});
+ it('expandReplaceRange handles multi-line self-closing
inside the wrapped element', () => {
+ // Cursor Bugbot regression: per-line depth tracking in
+ // `expandReplaceRange` couldn't see across line boundaries, so a
+ // multi-line self-closing JSX `
` got
+ // counted as +1 with no compensating -1. The wrapper's outer
+ // never matched the depth-zero condition; replace-range stopped at
+ // block.end (the marker comment), leaving the wrapper's outer
+ // orphaned in the file after accept/discard — and worse, an
+ // unrelated
right after the wrapper got
+ // its own
mis-counted as the wrapper close.
+ const tsx = `export default function App() {
+ return (
+
+
+ After
+
+ );
+}`;
+ writeFileSync(join(tmp, 'App.tsx'), tsx);
+
+ execSync(
+ `node source/skills/impeccable/scripts/live-wrap.mjs --id MULTILINESC --count 3 --classes "card" --tag "aside" --file "${join(tmp, 'App.tsx')}"`,
+ { cwd: process.cwd(), encoding: 'utf-8' }
+ );
+
+ const result = runAccept(tmp, ['--id', 'MULTILINESC', '--discard']);
+ assert.equal(result.handled, true, `discard should succeed: ${JSON.stringify(result)}`);
+
+ const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
+ // The wrapper scaffold must be fully gone — no orphan
from
+ // the outer wrapper, and no impeccable markers/data attributes.
+ assert.ok(!after.includes('data-impeccable-variants'),
+ `outer wrapper div must be fully removed; got:\n${after}`);
+ assert.ok(!after.includes('data-impeccable-variant'),
+ `original-div wrapper must be fully removed; got:\n${after}`);
+ assert.ok(!after.includes('impeccable-variants-start'),
+ `start marker must be removed; got:\n${after}`);
+ // The unrelated
After
sibling
+ // must survive intact — Bugbot's worst-case scenario was the depth
+ // walk eating its
'),
+ `unrelated next-card sibling must be preserved; got:\n${after}`);
+ // The multi-line self-closing div inside the original element must
+ // survive too.
+ assert.match(after, /
inside original must survive; got:\n${after}`);
+ });
+
it('accept (no carbonize, raw HTML) restores at the original indent on JSX', () => {
// Manually craft a wrapped file in the JSX-marker-inside layout — this
// mirrors what wrap produces, but lets us exercise accept's indent