3 天以前 b1ad2d17c7f93e819c67fe5ab5c2ca94e764e517
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
/**
 * CLI helper: find an element in source and wrap it in a variant container.
 *
 * Usage:
 *   node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
 *
 * Searches project files for the element matching the query (class name, ID, or
 * text snippet), wraps it with the variant scaffolding, and prints the file path
 * + line range where the agent should insert variant HTML.
 *
 * This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
 */
 
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { findSourceFile } from './live/source-search.mjs';
import { resolveSourceTraits } from './live/frameworks/index.mjs';
import {
  buildSvelteComponentCssAuthoring,
  scaffoldSvelteComponentSession,
  shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
import { enterLiveRoot } from './live/roots.mjs';
 
export async function wrapCli() {
  const args = process.argv.slice(2);
 
  if (args.includes('--help') || args.includes('-h')) {
    console.log(`Usage: impeccable wrap [options]
 
Find an element in source and wrap it in a variant container.
 
Required:
  --id ID            Session ID for the variant wrapper
  --count N          Number of expected variants (1-8)
 
Element identification (at least one required):
  --element-id ID    HTML id attribute of the element
  --classes A,B,C    Comma- or space-separated CSS class names
  --tag TAG          Tag name (div, section, etc.)
  --query TEXT       Fallback: raw text to search for
 
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.
  --page-url URL     Current page URL. Required when pending manual edits may
                     affect the picked source block. Pending edits are filtered
                     to this page so an edit on /a doesn't bleed into /b.
  --help             Show this help message
 
Output (JSON):
  { file, startLine, endLine, insertLine, commentSyntax }
 
The agent should insert variant HTML at insertLine.`);
    process.exit(0);
  }
 
  const id = argVal(args, '--id');
  const count = parseInt(argVal(args, '--count') || '3');
  const elementId = argVal(args, '--element-id');
  const classes = argVal(args, '--classes');
  const tag = argVal(args, '--tag');
  const query = argVal(args, '--query');
  const filePath = argVal(args, '--file');
  const text = argVal(args, '--text');
  const pageUrl = argVal(args, '--page-url');
  // Preflight passes this for source-preview targets. It computes the scaffold
  // (element location + wrapper text) but does NOT write it into source. The
  // agent then writes the wrapper + all variants in one atomic edit. The
  // premature server-side write full-reloaded the framework mid-generate and
  // stranded the browser at 0/N (live-server.mjs missed-completion note). It is
  // a no-op on the svelte-component path, which never writes the route source.
  const deferSourceWrite = args.includes('--defer-source-write');
 
  if (!id) { console.error('Missing --id'); process.exit(1); }
  if (!elementId && !classes && !query) {
    console.error('Need at least one of: --element-id, --classes, --query');
    process.exit(1);
  }
 
  // Build search queries in priority order (most specific first)
  const queries = buildSearchQueries(elementId, classes, tag, query);
 
  const genOpts = { cwd: process.cwd() };
 
  // Find the source file. Generated files are excluded from auto-search so we
  // don't silently write variants into a file the next build will wipe.
  let targetFile = filePath;
  let matchedQuery = null;
  if (!targetFile) {
    for (const q of queries) {
      targetFile = findFileWithQuery(q, process.cwd(), genOpts);
      if (targetFile) { matchedQuery = q; break; }
    }
    if (!targetFile) {
      // Nothing in source. Did the element show up in a generated file? That
      // tells the agent "fall back to the agent-driven flow" vs "element just
      // doesn't exist in this project."
      let generatedHit = null;
      for (const q of queries) {
        generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
        if (generatedHit) break;
      }
      if (generatedHit) {
        console.error(JSON.stringify({
          error: 'element_not_in_source',
          fallback: 'agent-driven',
          generatedMatch: path.relative(process.cwd(), generatedHit),
          hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
        }));
      } else {
        console.error(JSON.stringify({
          error: 'element_not_found',
          fallback: 'agent-driven',
          hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
        }));
      }
      process.exit(1);
    }
  } else {
    if (isGeneratedFile(targetFile, genOpts)) {
      console.error(JSON.stringify({
        error: 'file_is_generated',
        fallback: 'agent-driven',
        file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
        hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
      }));
      process.exit(1);
    }
    matchedQuery = queries[0];
  }
 
  const content = fs.readFileSync(targetFile, 'utf-8');
  const lines = content.split('\n');
 
  // 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;
  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) {
        const normalizedText = String(text).replace(/\s+/g, ' ').trim();
        if (normalizedText.length < 8) {
          // Very short labels cannot disambiguate siblings reliably. Preserve
          // the legacy behavior for these low-information picker events.
          match = candidates[0];
        } else {
          // Rendered text that is absent from every candidate usually means
          // the source uses expressions or component props. Picking the first
          // same-class sibling silently edits the wrong instance (observed on
          // Astro result cards), so stop and surface every candidate instead.
          console.error(JSON.stringify({
            error: 'element_ambiguous',
            fallback: 'agent-driven',
            reason: 'rendered_text_not_in_source',
            file: path.relative(process.cwd(), targetFile),
            candidates: candidates.map((c) => ({
              startLine: c.startLine + 1,
              endLine: c.endLine + 1,
            })),
            hint: 'Rendered text does not occur in any matching source branch. The element may use dynamic props or expressions; inspect the candidates and wrap the intended instance manually.',
          }));
          process.exit(1);
        }
      } 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;
  const commentSyntax = detectCommentSyntax(targetFile);
  const styleMode = detectStyleMode(targetFile);
  const isJsx = commentSyntax.open === '{/*';
  const indent = lines[startLine].match(/^(\s*)/)[1];
 
  // Extract the original element. Reindent under the wrapper while preserving
  // the relative depth between lines — `l.trimStart()` would strip ALL leading
  // whitespace and collapse e.g. `<aside>`/`  <h1>`/`</aside>` (6/8/6 spaces)
  // to a single uniform indent, so on accept/discard the round-trip restores
  // the inner element at its parent's depth instead of nested inside it.
  // Strip only the COMMON minimum leading whitespace across the picked lines;
  // `deindentContent` on the accept side already mirrors this convention.
  let originalLines = lines.slice(startLine, endLine + 1);
 
  // Buffer-aware "original" content: if the user has pending manual edits for
  // this page whose originalText appears in the picked source range, apply
  // them so the wrap block's "original" variant reflects what the user was
  // looking at (their edited DOM), not the raw source. Source itself stays
  // untouched here — only the wrap block's embedded "original" copy is
  // adjusted. The pending edits remain in the buffer until committed.
  //
  // Apply buffered edits only when the browser provided the current page URL.
  // Without it, fail if pending edits plausibly touch this exact source range;
  // otherwise skip buffer awareness so unrelated staged edits on another page
  // do not block normal wrap work.
  let pendingBuffer = { entries: [] };
  try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {}
  const pendingEntriesForTarget = pageUrl
    ? []
    : pendingEntriesThatMayAffectWrap(pendingBuffer.entries, targetFile, originalLines, startLine, process.cwd());
  if (pendingEntriesForTarget.length > 0) {
    console.error(JSON.stringify({
      error: 'missing_page_url_with_pending_edits',
      pendingEntries: pendingEntriesForTarget.length,
      hint: 'Pending manual edits may affect the selected source block. Pass --page-url=$event.pageUrl so the wrap block reflects the user\'s staged DOM.',
    }));
    process.exit(1);
  }
  if (pageUrl) {
    const failedBufferedOps = [];
    for (const entry of pendingBuffer.entries || []) {
      if (entry.pageUrl !== pageUrl) continue;
      for (const op of entry.ops || []) {
        const mayAffectWrap = manualEditMayAffectWrap(op, targetFile, originalLines, startLine, process.cwd());
        const result = applyBufferedManualEditToLines(originalLines, startLine, op);
        if (result.changed) {
          originalLines = result.lines;
          continue;
        }
        if (!mayAffectWrap) continue;
        failedBufferedOps.push({
          entryId: entry.id,
          ref: op?.ref || null,
          originalText: op?.originalText || null,
          reason: 'ambiguous_or_unmatched_pending_edit',
        });
      }
    }
    if (failedBufferedOps.length > 0) {
      console.error(JSON.stringify({
        error: 'manual_edit_buffer_apply_failed',
        pendingOps: failedBufferedOps,
        hint: 'A staged copy edit appears to affect the selected source block, but could not be applied unambiguously to the wrap original. Apply or discard copy edits first, or write the wrapper manually.',
      }));
      process.exit(1);
    }
  }
 
  const originalBaseIndent = minLeadingSpaces(originalLines);
  const reindentOriginal = (extra) => originalLines
    .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
    .join('\n');
  const originalIndented = reindentOriginal('    ');
  const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
  // The registry says which files get component preview; the svelte-component
  // module keeps the env escape hatch that turns it off.
  const useSvelteComponent = resolveSourceTraits(targetFile).preview === 'component'
    && shouldUseSvelteComponentInjection(targetFile);
 
  // 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"';
 
  // 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">',
    reindentOriginal('    '),
    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,
    indent + '  <div data-impeccable-variant="original">',
    originalIndented,
    indent + '  </div>',
    indent + '  ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
    indent + '</div>',
    indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
  ];
 
  let outputFile = targetFile;
  let outputLines;
  let outputStartLine = startLine + 1;
  let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
  let insertLine;
  let svelteSession = null;
  let deferredWrapper = null;
 
  let sveltePreviewFallback = null;
  if (useSvelteComponent) {
    // Svelte/SvelteKit resets component-local state on markup HMR updates.
    // Keep generation source-neutral: agents write real variant components
    // under the generated componentDir, the browser mounts them into the live
    // DOM, and live-accept.mjs inlines the accepted variant back into the route.
    //
    // The scaffold is AST-based and refuses markup a detached preview cannot
    // support (component tags, bind:/use:, await blocks, bound nested each).
    // Refusal falls back to the plain source-preview wrapper below: an
    // HMR-resetting but CORRECT preview beats a detached wrong one.
    const scaffolded = scaffoldSvelteComponentSession({
      id,
      count,
      sourceFile: relTargetFile,
      sourceStartLine: startLine + 1,
      sourceEndLine: endLine + 1,
      originalLines,
      cwd: process.cwd(),
    });
    if (scaffolded && scaffolded.fallback === 'source-preview') {
      sveltePreviewFallback = scaffolded.reason || 'unsupported markup';
    } else {
      svelteSession = scaffolded;
      outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
      outputStartLine = 1;
      outputEndLine = 1;
      insertLine = 1;
    }
  }
  if (svelteSession) {
    // component preview: outputs already set above
  } else if (deferSourceWrite) {
    // Deferred source write: compute the scaffold text but leave source
    // untouched. The agent replaces the picked element's source range with
    // `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
    // scaffold here first would reload the framework before the agent's write
    // lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
    deferredWrapper = {
      block: wrapperLines.join('\n'),
      replaceStartLine: startLine + 1, // 1-indexed picked-element range the
      replaceEndLine: endLine + 1,     // agent's wrapper block replaces
    };
    // insertLine matches the final file position the wrapper occupies once the
    // agent replaces the picked range, so downstream consumers stay consistent.
    insertLine = startLine + 6 + (originalLines.length - 1) + 1;
  } else {
    // Replace the original element with the wrapper
    const newLines = [
      ...lines.slice(0, startLine),
      ...wrapperLines,
      ...lines.slice(endLine + 1),
    ];
    fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
 
    // 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.
    insertLine = startLine + 6 + (originalLines.length - 1) + 1;
  }
 
  const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
 
  const componentPreviewActive = !!svelteSession;
  const svelteComponentAuthoring = componentPreviewActive ? buildSvelteComponentCssAuthoring(count) : null;
  const componentSession = svelteSession;
  const componentPreviewMode = componentPreviewActive ? 'svelte-component' : undefined;
  const previewMode = componentPreviewMode;
 
  console.log(JSON.stringify({
    file: outputRelFile,
    sourceFile: componentPreviewActive ? relTargetFile : undefined,
    previewMode,
    previewFallback: sveltePreviewFallback
      ? { from: 'svelte-component', reason: sveltePreviewFallback }
      : undefined,
    // Deferred source write: the wrapper is NOT yet in source. The agent
    // replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
    // spliced at the "insert below this line" marker) in one atomic edit.
    sourceWritten: deferredWrapper ? false : undefined,
    wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
    replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
    replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
    componentDir: componentSession?.componentDir,
    propContract: componentSession?.propContract,
    componentStubMarkup: componentSession?.stubMarkup,
    sourceStartLine: componentPreviewActive ? startLine + 1 : undefined,
    sourceEndLine: componentPreviewActive ? endLine + 1 : undefined,
    startLine: outputStartLine,       // 1-indexed for the agent
    // 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: outputEndLine, // 1-indexed
    insertLine,            // 1-indexed: where variants go
    commentSyntax: commentSyntax,
    styleMode: componentPreviewMode || styleMode.mode,
    styleTag: componentPreviewActive ? null : styleMode.styleTag,
    cssSelectorPrefixExamples: componentPreviewActive ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
    cssAuthoring: svelteComponentAuthoring || buildCssAuthoring(styleMode, count),
    originalLineCount: originalLines.length,
  }));
}
 
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
 
function argVal(args, flag) {
  const prefix = flag + '=';
  for (const arg of args) {
    if (arg.startsWith(prefix)) return arg.slice(prefix.length);
  }
  const idx = args.indexOf(flag);
  return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
 
function pendingEntriesThatMayAffectWrap(entries, targetFile, originalLines, selectionStartLine, cwd) {
  const targetAbs = path.resolve(cwd, targetFile);
  return (entries || []).filter((entry) => {
    return (entry.ops || []).some((op) => {
      return manualEditMayAffectWrap(op, targetAbs, originalLines, selectionStartLine, cwd);
    });
  });
}
 
function manualEditMayAffectWrap(op, targetFile, originalLines, selectionStartLine, cwd) {
  const targetAbs = path.resolve(cwd, targetFile);
  if (manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd)) return true;
  if (manualEditLocatorMatchesSelection(op, originalLines)) return true;
  if (typeof op?.originalText === 'string' && op.originalText.length > 0) {
    return originalLines.join('\n').includes(op.originalText);
  }
  return false;
}
 
function manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd) {
  const hintFile = op?.sourceHint?.file;
  const hintedLine = Number(op?.sourceHint?.line);
  if (!hintFile || !Number.isFinite(hintedLine)) return false;
  const hintAbs = path.isAbsolute(hintFile) ? hintFile : path.resolve(cwd, hintFile);
  if (path.resolve(hintAbs) !== targetAbs) return false;
  const hintedIndex = hintedLine - 1 - selectionStartLine;
  return hintedIndex >= 0
    && hintedIndex < originalLines.length
    && typeof op?.originalText === 'string'
    && originalLines[hintedIndex].includes(op.originalText);
}
 
function manualEditLocatorMatchesSelection(op, originalLines) {
  if (!op || typeof op.originalText !== 'string' || op.originalText.length === 0) return false;
  return originalLines.some((line) => (
    line.includes(op.originalText) && lineMatchesManualEditLocator(line, op)
  ));
}
 
function applyBufferedManualEditToLines(originalLines, selectionStartLine, op) {
  if (
    !op
    || typeof op.originalText !== 'string'
    || op.originalText.length === 0
    || typeof op.newText !== 'string'
  ) {
    return { lines: originalLines, changed: false };
  }
 
  const replaceLine = (lineIndex) => ({
    lines: originalLines.map((line, index) => (
      index === lineIndex ? replaceOnce(line, op.originalText, op.newText) : line
    )),
    changed: true,
  });
 
  const hintedLine = Number(op.sourceHint?.line);
  if (Number.isFinite(hintedLine)) {
    const hintedIndex = hintedLine - 1 - selectionStartLine;
    if (hintedIndex >= 0 && hintedIndex < originalLines.length && originalLines[hintedIndex].includes(op.originalText)) {
      return replaceLine(hintedIndex);
    }
  }
 
  const locatorMatches = [];
  for (let index = 0; index < originalLines.length; index += 1) {
    const line = originalLines[index];
    if (!line.includes(op.originalText)) continue;
    if (!lineMatchesManualEditLocator(line, op)) continue;
    locatorMatches.push(index);
  }
  if (locatorMatches.length === 1) return replaceLine(locatorMatches[0]);
 
  const originalBlock = originalLines.join('\n');
  if (countOccurrences(originalBlock, op.originalText) === 1) {
    return {
      lines: replaceOnce(originalBlock, op.originalText, op.newText).split('\n'),
      changed: true,
    };
  }
 
  return { lines: originalLines, changed: false };
}
 
function lineMatchesManualEditLocator(line, op) {
  if (op.tag) {
    const tagRe = new RegExp('<\\s*' + escapeRegExp(op.tag) + '(?=[\\s>/]|$)', 'i');
    if (!tagRe.test(line)) return false;
  }
 
  if (op.elementId) {
    const id = escapeRegExp(op.elementId);
    const idRe = new RegExp('\\bid\\s*=\\s*["\']' + id + '["\']');
    if (!idRe.test(line)) return false;
  }
 
  const classes = Array.isArray(op.classes) ? op.classes.filter(Boolean) : [];
  for (const className of classes) {
    if (!line.includes(className)) return false;
  }
 
  return true;
}
 
function replaceOnce(value, needle, replacement) {
  const index = value.indexOf(needle);
  if (index === -1) return value;
  return value.slice(0, index) + replacement + value.slice(index + needle.length);
}
 
function countOccurrences(value, needle) {
  if (!needle) return 0;
  let count = 0;
  let index = 0;
  while (true) {
    index = value.indexOf(needle, index);
    if (index === -1) return count;
    count += 1;
    index += needle.length;
  }
}
 
function escapeRegExp(value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
 
/**
 * Build search query strings in priority order (most specific first).
 * ID is most reliable, then specific class combos, then single classes, then raw query.
 */
function buildSearchQueries(elementId, classes, tag, query) {
  const queries = [];
 
  // 1. ID is the most specific
  if (elementId) {
    queries.push('id="' + elementId + '"');
  }
 
  // 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 = splitClassList(classes);
    if (classList.length > 1) {
      const joined = classList.join(' ');
      const sorted = [...classList].sort((a, b) => b.length - a.length);
      queries.push('class="' + joined + '"');
      queries.push('className="' + joined + '"');
      for (const className of sorted) {
        queries.push(className);
      }
    } else if (classList.length === 1) {
      queries.push(classList[0]);
    }
  }
 
  // 3. Tag + class combo (e.g., <section class="hero">).
  // Same dual-emit for JSX compatibility.
  if (tag && classes) {
    const firstClass = splitClassList(classes)[0];
    queries.push('<' + tag + ' class="' + firstClass);
    queries.push('<' + tag + ' className="' + firstClass);
  }
 
  // 4. Raw fallback query
  if (query) {
    queries.push(query);
  }
 
  return queries;
}
 
function splitClassList(classes) {
  return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
}
 
function attrEscapeDouble(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}
 
/**
 * Comment syntax, style mode, and preview strategy all come from the framework
 * registry, keyed on the target file's extension: `.jsx`/`.tsx` author JSX
 * comments, `.astro` needs global-prefixed preview CSS because Astro scopes
 * component styles away from the generated wrappers, `.svelte` gets component
 * preview. See live/frameworks/index.mjs for why extension and not project.
 */
function detectCommentSyntax(filePath) {
  return resolveSourceTraits(filePath).commentSyntax === 'jsx'
    ? { open: '{/*', close: '*/}' }
    : { open: '<!--', close: '-->' };
}
 
function detectStyleMode(filePath) {
  const traits = resolveSourceTraits(filePath);
  return { mode: traits.styleMode, styleTag: traits.styleTag };
}
 
function buildCssSelectorPrefixExamples(styleMode, count) {
  if (styleMode !== 'astro-global-prefixed') return [];
  return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
}
 
function buildCssAuthoring(styleMode, count) {
  const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
  if (styleMode.mode === 'astro-global-prefixed') {
    return {
      mode: styleMode.mode,
      styleTag: styleMode.styleTag,
      strategy: 'global-prefixed',
      rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
      selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
      requirements: [
        'Use the styleTag exactly; the is:inline attribute is required for this file.',
        'Put raw CSS directly between the styleTag opening and a plain </style> close.',
        'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
        'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
      ],
      forbidden: [
        'Do not use @scope for this styleMode.',
        'Do not wrap style content in a JSX/TSX template literal ({` ... `}); that syntax is for .tsx/.jsx only.',
        'Do not put { immediately after the style opening tag; Astro parses { as expression syntax.',
      ],
    };
  }
  return {
    mode: styleMode.mode,
    styleTag: styleMode.styleTag,
    strategy: 'scope-rule',
    rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
    selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
    requirements: [
      'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
      'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
      'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
    ],
    forbidden: [
      'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
      'Do not add is:inline to the style tag for this styleMode.',
    ],
  };
}
 
/**
 * Search project files for the query string (class name, ID, etc.)
 * Returns the first matching file path, or null.
 *
 * Only `node_modules`, `.git`, and `.impeccable` are skipped outright.
 * dist/build/out are left to the isGeneratedFile guard so the
 * `includeGenerated` second pass can still find the element there and report
 * `generatedMatch`.
 */
function findFileWithQuery(query, cwd, genOpts = {}) {
  return findSourceFile({
    query,
    cwd,
    extensions: resolveLiveTemplateExtensions(cwd),
    fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts),
  });
}
 
/**
 * 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. `<section\n  className="..."\n>`) are recognised.
 */
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
 * `<section\n  className="..."\n>` 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.
 */
/**
 * Return the smallest leading-whitespace count across a set of lines,
 * ignoring blank lines (whose indent isn't load-bearing). Used to compute
 * the common base indent of a multi-line picked element so reindenting
 * under the wrapper preserves the relative depth between lines.
 */
function minLeadingSpaces(lines) {
  let min = Infinity;
  for (const l of lines) {
    if (l.trim() === '') continue;
    const m = l.match(/^(\s*)/);
    if (m && m[1].length < min) min = m[1].length;
  }
  return min === Infinity ? 0 : min;
}
 
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)) continue;
 
    const stripped = lines[i].trim();
    if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
    // Skip lines already inside a variant wrapper
    if (lines[i].includes('data-impeccable-variant')) continue;
 
    const openerLine = findOpenerLine(lines, i, tag);
    if (openerLine === -1) continue;
 
    const endLine = findClosingLine(lines, openerLine);
    return { startLine: openerLine, endLine };
  }
 
  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 matches a meaningful
 * prefix of the picked element's textContent. The compare strips tags and
 * JSX expressions, then checks two whitespace normalizations side-by-side:
 *
 *   - single-space ("hero two second card body")
 *   - no-whitespace ("herotwosecondcardbody")
 *
 * Both are needed because `el.textContent` concatenates sibling text without
 * inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
 * `"Hero TwoSecond…"`), while the source has whitespace between tags. If
 * EITHER normalization matches, the candidate keeps. A snippet shorter than
 * 8 chars after stripping is too weak to disambiguate — the caller falls
 * back to first-match.
 */
function filterByText(candidates, lines, text) {
  const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
  // 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, '');
 
  return candidates.filter((c) => {
    const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
    const inner = body
      .replace(/<[^>]*>/g, ' ')   // strip HTML/JSX tags
      .replace(/\{[^}]*\}/g, ' ')  // strip JSX expressions
      .toLowerCase();
    const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
    const sourceCompact = inner.replace(/\s+/g, '');
    return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
  });
}
 
/**
 * 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
 * first tag opener. If `tag` is specified, the opener must match that tag
 * name; an opener with a different tag name aborts the backward walk for this
 * match (we don't jump across element boundaries).
 *
 * Returns the line index of the opener, or -1 if none can be resolved.
 */
function findOpenerLine(lines, matchLine, tag) {
  const self = lines[matchLine].match(OPENER_RE);
  if (self) {
    if (!tag || self[1] === tag) return matchLine;
    return -1;
  }
  const MAX_BACKWALK = 10;
  for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
    const opener = lines[i].match(OPENER_RE);
    if (!opener) continue;
    if (!tag || opener[1] === tag) return i;
    // Different tag name than requested — abort; we're inside a non-target opener.
    return -1;
  }
  return -1;
}
 
/**
 * Starting from a line with an opening tag, find the line with the matching
 * closing tag by counting tag nesting depth.
 */
function findClosingLine(lines, start) {
  const openMatch = lines[start].match(OPENER_RE);
  if (!openMatch) return start; // caller passed a non-opener; nothing to span
 
  const tagName = openMatch[1];
  let depth = 0;
  const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
  const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
  const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
 
  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) return i;
  }
 
  // If we can't find the close, return a reasonable guess
  return Math.min(start + 50, lines.length - 1);
}
 
// Auto-execute when run directly (node live-wrap.mjs ...)
const _running = process.argv[1];
if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
  enterLiveRoot();
  wrapCli();
}
 
// Test exports (used by tests/live-wrap.test.mjs)
export {
  buildSearchQueries,
  findElement,
  findClosingLine,
  detectCommentSyntax,
  findAllElements,
  filterByText,
  findFileWithQuery,
  detectStyleMode,
  buildCssAuthoring,
  buildCssSelectorPrefixExamples,
};