component-mapper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,694 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from component_mapper.config import MapperSettings
8
+ from component_mapper.models import (
9
+ ComponentSignature,
10
+ RankedCandidate,
11
+ RegistrySource,
12
+ InteractivityMode,
13
+ CustomComponentDefinition,
14
+ )
15
+ from component_mapper.utils.source_parser import parse_source
16
+ from component_mapper.utils.similarity import (
17
+ skeleton_similarity,
18
+ jaccard_similarity,
19
+ composite_score,
20
+ rank_candidates,
21
+ )
22
+ from component_mapper.mcp.official_client import OfficialMCPClient
23
+ from component_mapper.mcp.registry_fetcher import RegistryFetcher
24
+ from segment_classifier.models import ComponentType
25
+ from segment_classifier.utils.html_normalizer import NormalizedSegment
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Static type map: ComponentType → list of Shadcn component names
30
+ TYPE_MAP: dict[ComponentType, list[str]] = {
31
+ ComponentType.LAYOUT_HEADER: ["navigation-menu"],
32
+ ComponentType.LAYOUT_FOOTER: [],
33
+ ComponentType.LAYOUT_NAV: ["navigation-menu", "menubar"],
34
+ ComponentType.LAYOUT_SIDEBAR: ["sidebar"],
35
+ ComponentType.LAYOUT_BREADCRUMB: ["breadcrumb"],
36
+ ComponentType.COLLECTION_PRODUCT_CARD: ["card"],
37
+ ComponentType.COLLECTION_BLOG_CARD: ["card"],
38
+ ComponentType.COLLECTION_NEWS_ITEM: ["card"],
39
+ ComponentType.COLLECTION_PRODUCT_LIST: ["table"],
40
+ ComponentType.COLLECTION_BLOG_LIST: ["card"],
41
+ ComponentType.COLLECTION_NEWS_LIST: ["card"],
42
+ ComponentType.SECTION_HERO: [],
43
+ ComponentType.SECTION_FEATURE_GRID: ["card"],
44
+ ComponentType.SECTION_TESTIMONIAL: ["card"],
45
+ ComponentType.SECTION_CTA: ["card", "button"],
46
+ ComponentType.SECTION_FAQ: ["accordion"],
47
+ ComponentType.SECTION_PRICING: ["card"],
48
+ ComponentType.UI_FORM: ["form", "input", "select"],
49
+ ComponentType.UI_MODAL: ["dialog", "sheet"],
50
+ ComponentType.UI_TABLE: ["table"],
51
+ ComponentType.UI_CAROUSEL: ["carousel"],
52
+ ComponentType.UI_PAGINATION: ["pagination", "bundui/pagination", "hextaui/pagination"],
53
+ ComponentType.UI_SEARCH: ["command"],
54
+ ComponentType.CONTENT_ARTICLE: [],
55
+ ComponentType.CONTENT_RICH_TEXT: [],
56
+ ComponentType.CONTENT_MEDIA: ["aspect-ratio"],
57
+ ComponentType.UNKNOWN: [],
58
+ }
59
+
60
+ # Known Shadcn components (fallback if MCP unavailable)
61
+ KNOWN_SHADCN_COMPONENTS = [
62
+ "accordion",
63
+ "alert",
64
+ "alert-dialog",
65
+ "aspect-ratio",
66
+ "avatar",
67
+ "badge",
68
+ "breadcrumb",
69
+ "button",
70
+ "calendar",
71
+ "card",
72
+ "carousel",
73
+ "checkbox",
74
+ "collapsible",
75
+ "command",
76
+ "context-menu",
77
+ "data-table",
78
+ "date-picker",
79
+ "dialog",
80
+ "drawer",
81
+ "dropdown-menu",
82
+ "form",
83
+ "hover-card",
84
+ "input",
85
+ "input-otp",
86
+ "label",
87
+ "menubar",
88
+ "navigation-menu",
89
+ "pagination",
90
+ "popover",
91
+ "progress",
92
+ "radio-group",
93
+ "resizable",
94
+ "scroll-area",
95
+ "select",
96
+ "separator",
97
+ "sheet",
98
+ "sidebar",
99
+ "skeleton",
100
+ "slider",
101
+ "sonner",
102
+ "switch",
103
+ "table",
104
+ "tabs",
105
+ "textarea",
106
+ "toast",
107
+ "toggle",
108
+ "toggle-group",
109
+ "tooltip",
110
+ ]
111
+
112
+ # Component type hints for building signatures without source code
113
+ COMPONENT_HINTS: dict[str, dict[str, Any]] = {
114
+ "card": {
115
+ "compatible": [
116
+ ComponentType.COLLECTION_PRODUCT_CARD,
117
+ ComponentType.COLLECTION_BLOG_CARD,
118
+ ComponentType.SECTION_FEATURE_GRID,
119
+ ComponentType.SECTION_TESTIMONIAL,
120
+ ComponentType.SECTION_CTA,
121
+ ComponentType.SECTION_PRICING,
122
+ ],
123
+ "skeleton": "div>[div>h3+p, div, div>button]",
124
+ "root": "div",
125
+ "classes": ["card", "content", "header", "footer"],
126
+ },
127
+ "navigation-menu": {
128
+ "compatible": [ComponentType.LAYOUT_HEADER, ComponentType.LAYOUT_NAV],
129
+ "skeleton": "nav>[ul>[li>a]]",
130
+ "root": "nav",
131
+ "classes": ["nav", "menu"],
132
+ },
133
+ "accordion": {
134
+ "compatible": [ComponentType.SECTION_FAQ],
135
+ "skeleton": "div>[div>button+div>p]",
136
+ "root": "div",
137
+ "classes": ["accordion", "item"],
138
+ },
139
+ "dialog": {
140
+ "compatible": [ComponentType.UI_MODAL],
141
+ "skeleton": "div>[div>h2+p+div>button]",
142
+ "root": "div",
143
+ "classes": ["modal", "dialog"],
144
+ },
145
+ "table": {
146
+ "compatible": [ComponentType.UI_TABLE, ComponentType.COLLECTION_PRODUCT_LIST],
147
+ "skeleton": "table>[thead>tr>th, tbody>tr>td]",
148
+ "root": "table",
149
+ "classes": ["table"],
150
+ },
151
+ "carousel": {
152
+ "compatible": [ComponentType.UI_CAROUSEL],
153
+ "skeleton": "div>[div>[div>img], button, button]",
154
+ "root": "div",
155
+ "classes": ["carousel"],
156
+ },
157
+ "form": {
158
+ "compatible": [ComponentType.UI_FORM],
159
+ "skeleton": "form>[div>label+input, button]",
160
+ "root": "form",
161
+ "classes": ["form"],
162
+ },
163
+ "command": {
164
+ "compatible": [ComponentType.UI_SEARCH],
165
+ "skeleton": "div>[input, div>[div>span]]",
166
+ "root": "div",
167
+ "classes": ["search", "command"],
168
+ },
169
+ "pagination": {
170
+ "compatible": [ComponentType.UI_PAGINATION],
171
+ "skeleton": "nav>[ul>[li>a]+li>a+[li>a]]",
172
+ "root": "nav",
173
+ "classes": ["pagination"],
174
+ "install": "npx shadcn@latest add pagination",
175
+ },
176
+ # ── External open-source registries (verified from ui.shadcn.com/docs/directory) ──
177
+ "bundui/pagination": {
178
+ "compatible": [ComponentType.UI_PAGINATION],
179
+ # Same nav>ul>li>a structure; bundui wraps the official pagination with
180
+ # its own styling tokens — skeleton is identical to the official component.
181
+ "skeleton": "nav>[ul>[li>a+li>a+li>a+li>span+li>a]]",
182
+ "root": "nav",
183
+ "classes": ["pagination"],
184
+ "install": "npx shadcn@latest add @bundui/pagination",
185
+ "registry": "bundui",
186
+ "namespace": "@bundui",
187
+ },
188
+ "hextaui/pagination": {
189
+ "compatible": [ComponentType.UI_PAGINATION],
190
+ # HextaUI pagination uses data-slot attributes and buttonVariants;
191
+ # structure matches official but adds touch-manipulation and tabular-nums.
192
+ "skeleton": "nav>[ul>[li>a+li>a+li>a+li>span+li>a]]",
193
+ "root": "nav",
194
+ "classes": ["pagination"],
195
+ "install": "npx shadcn@latest add @hextaui/pagination",
196
+ "registry": "hextaui",
197
+ "namespace": "@hextaui",
198
+ },
199
+ "breadcrumb": {
200
+ "compatible": [ComponentType.LAYOUT_BREADCRUMB],
201
+ "skeleton": "nav>[ol>[li>a+li>a]]",
202
+ "root": "nav",
203
+ "classes": ["breadcrumb"],
204
+ },
205
+ "sidebar": {
206
+ "compatible": [ComponentType.LAYOUT_SIDEBAR],
207
+ "skeleton": "aside>[div>[nav>ul>li>a]]",
208
+ "root": "aside",
209
+ "classes": ["sidebar", "nav"],
210
+ },
211
+ "sheet": {
212
+ "compatible": [ComponentType.UI_MODAL],
213
+ "skeleton": "div>[div>h2+p+div]",
214
+ "root": "div",
215
+ "classes": ["modal", "sheet"],
216
+ },
217
+ "aspect-ratio": {
218
+ "compatible": [ComponentType.CONTENT_MEDIA],
219
+ "skeleton": "div>[img]",
220
+ "root": "div",
221
+ "classes": ["media"],
222
+ },
223
+ "menubar": {
224
+ "compatible": [ComponentType.LAYOUT_NAV],
225
+ "skeleton": "div>[div>[button]]",
226
+ "root": "div",
227
+ "classes": ["menu", "nav"],
228
+ },
229
+ "select": {
230
+ "compatible": [ComponentType.UI_FORM],
231
+ "skeleton": "div>[button>span+span, div>[div>span]]",
232
+ "root": "div",
233
+ "classes": ["form", "select"],
234
+ },
235
+ "input": {
236
+ "compatible": [ComponentType.UI_FORM],
237
+ "skeleton": "input",
238
+ "root": "input",
239
+ "classes": ["form", "input"],
240
+ },
241
+ "button": {
242
+ "compatible": [ComponentType.SECTION_CTA],
243
+ "skeleton": "button>span",
244
+ "root": "button",
245
+ "classes": [],
246
+ },
247
+ "badge": {
248
+ "compatible": [],
249
+ "skeleton": "span",
250
+ "root": "span",
251
+ "classes": ["badge"],
252
+ },
253
+ "avatar": {
254
+ "compatible": [],
255
+ "skeleton": "span>[img, span]",
256
+ "root": "span",
257
+ "classes": [],
258
+ },
259
+ "tabs": {
260
+ "compatible": [],
261
+ "skeleton": "div>[div>[button], div>[div]]",
262
+ "root": "div",
263
+ "classes": [],
264
+ },
265
+ "separator": {"compatible": [], "skeleton": "div", "root": "div", "classes": []},
266
+ "skeleton": {"compatible": [], "skeleton": "div", "root": "div", "classes": []},
267
+ "progress": {
268
+ "compatible": [],
269
+ "skeleton": "div>[div]",
270
+ "root": "div",
271
+ "classes": [],
272
+ },
273
+ "slider": {
274
+ "compatible": [],
275
+ "skeleton": "div>[span+span+span]",
276
+ "root": "div",
277
+ "classes": [],
278
+ },
279
+ "checkbox": {
280
+ "compatible": [],
281
+ "skeleton": "button",
282
+ "root": "button",
283
+ "classes": [],
284
+ },
285
+ "radio-group": {
286
+ "compatible": [],
287
+ "skeleton": "div>[div>[button+label]]",
288
+ "root": "div",
289
+ "classes": ["form"],
290
+ },
291
+ "switch": {
292
+ "compatible": [],
293
+ "skeleton": "button>span",
294
+ "root": "button",
295
+ "classes": [],
296
+ },
297
+ "toggle": {"compatible": [], "skeleton": "button", "root": "button", "classes": []},
298
+ "tooltip": {
299
+ "compatible": [],
300
+ "skeleton": "div>[button, div>p]",
301
+ "root": "div",
302
+ "classes": [],
303
+ },
304
+ "popover": {
305
+ "compatible": [],
306
+ "skeleton": "div>[button, div>div]",
307
+ "root": "div",
308
+ "classes": [],
309
+ },
310
+ "hover-card": {
311
+ "compatible": [],
312
+ "skeleton": "div>[span, div>div]",
313
+ "root": "div",
314
+ "classes": [],
315
+ },
316
+ "alert": {
317
+ "compatible": [],
318
+ "skeleton": "div>[span+h5+p]",
319
+ "root": "div",
320
+ "classes": [],
321
+ },
322
+ "alert-dialog": {
323
+ "compatible": [ComponentType.UI_MODAL],
324
+ "skeleton": "div>[div>[h2+p+div>[button+button]]]",
325
+ "root": "div",
326
+ "classes": ["modal"],
327
+ },
328
+ "calendar": {
329
+ "compatible": [],
330
+ "skeleton": "div>[div>[button+div+button], table>[thead>tr>th, tbody>tr>td]]",
331
+ "root": "div",
332
+ "classes": [],
333
+ },
334
+ "collapsible": {
335
+ "compatible": [],
336
+ "skeleton": "div>[div>button, div]",
337
+ "root": "div",
338
+ "classes": [],
339
+ },
340
+ "context-menu": {
341
+ "compatible": [],
342
+ "skeleton": "div>[div>span]",
343
+ "root": "div",
344
+ "classes": ["menu"],
345
+ },
346
+ "dropdown-menu": {
347
+ "compatible": [],
348
+ "skeleton": "div>[button, div>[div>span]]",
349
+ "root": "div",
350
+ "classes": ["menu"],
351
+ },
352
+ "label": {"compatible": [], "skeleton": "label", "root": "label", "classes": []},
353
+ "scroll-area": {
354
+ "compatible": [],
355
+ "skeleton": "div>[div, div>div]",
356
+ "root": "div",
357
+ "classes": [],
358
+ },
359
+ "sonner": {
360
+ "compatible": [],
361
+ "skeleton": "section>[ol>[li]]",
362
+ "root": "section",
363
+ "classes": [],
364
+ },
365
+ "resizable": {
366
+ "compatible": [],
367
+ "skeleton": "div>[div+div+div]",
368
+ "root": "div",
369
+ "classes": [],
370
+ },
371
+ "drawer": {
372
+ "compatible": [ComponentType.UI_MODAL],
373
+ "skeleton": "div>[div>[div>h2+p+div]]",
374
+ "root": "div",
375
+ "classes": ["modal"],
376
+ },
377
+ "input-otp": {
378
+ "compatible": [],
379
+ "skeleton": "div>[div>[div>input]]",
380
+ "root": "div",
381
+ "classes": ["form"],
382
+ },
383
+ "toggle-group": {
384
+ "compatible": [],
385
+ "skeleton": "div>[button]",
386
+ "root": "div",
387
+ "classes": [],
388
+ },
389
+ "toast": {
390
+ "compatible": [],
391
+ "skeleton": "div>[div>[div>p]]",
392
+ "root": "div",
393
+ "classes": [],
394
+ },
395
+ "date-picker": {
396
+ "compatible": [],
397
+ "skeleton": "div>[button>span+span, div]",
398
+ "root": "div",
399
+ "classes": ["form"],
400
+ },
401
+ "data-table": {
402
+ "compatible": [ComponentType.COLLECTION_PRODUCT_LIST, ComponentType.UI_TABLE],
403
+ "skeleton": "div>[div>input, div>[table>[thead>tr>th, tbody>tr>td]], div>[button]]",
404
+ "root": "div",
405
+ "classes": ["table"],
406
+ },
407
+ }
408
+
409
+
410
+ def _build_signature_from_hints(name: str, hints: dict) -> ComponentSignature:
411
+ """Build a ComponentSignature from known hints (no source parsing needed)."""
412
+ classes = hints.get("classes", [])
413
+ # For external registry components (name contains "/"), use the registry namespace
414
+ # for the install command and derive a clean Astro import from the base component name.
415
+ base_name = name.split("/")[-1] # "bundui/pagination" → "pagination"
416
+ is_external = "/" in name
417
+ astro_import = f"@/components/ui/{base_name}"
418
+ install_cmd = hints.get("install") or (
419
+ f"npx shadcn@latest add {hints.get('namespace', '')}/{base_name}"
420
+ if is_external
421
+ else f"npx shadcn@latest add {name}"
422
+ )
423
+ description = hints.get("description") or (
424
+ f"{hints.get('registry', 'External').capitalize()} {base_name} component"
425
+ if is_external
426
+ else f"Shadcn {name} component"
427
+ )
428
+ return ComponentSignature(
429
+ component_name=name,
430
+ registry_source=RegistrySource.SHADCN,
431
+ dom_skeleton=hints.get("skeleton", "div"),
432
+ root_element=hints.get("root", "div"),
433
+ required_children=[],
434
+ optional_children=[],
435
+ structural_class_tokens=classes,
436
+ typical_nesting_depth=hints.get("skeleton", "").count(">"),
437
+ child_tag_counts={},
438
+ unique_tag_count=0,
439
+ compatible_component_types=hints.get("compatible", []),
440
+ interactivity=InteractivityMode.STATIC,
441
+ description=description,
442
+ props=[],
443
+ astro_import=astro_import,
444
+ install_command=install_cmd,
445
+ requires_client_directive=False,
446
+ )
447
+
448
+
449
+ def _build_signature_from_parsed(name: str, parsed) -> ComponentSignature:
450
+ """Build ComponentSignature from ParsedSource."""
451
+ hints = COMPONENT_HINTS.get(name, {})
452
+ return ComponentSignature(
453
+ component_name=name,
454
+ registry_source=RegistrySource.SHADCN,
455
+ dom_skeleton=parsed.dom_skeleton or hints.get("skeleton", "div"),
456
+ root_element=parsed.root_element or hints.get("root", "div"),
457
+ required_children=parsed.required_children,
458
+ optional_children=parsed.optional_children,
459
+ structural_class_tokens=parsed.structural_class_tokens
460
+ or hints.get("classes", []),
461
+ typical_nesting_depth=parsed.typical_nesting_depth,
462
+ child_tag_counts=parsed.child_tag_counts,
463
+ unique_tag_count=len(parsed.child_tag_counts),
464
+ compatible_component_types=hints.get("compatible", []),
465
+ interactivity=parsed.interactivity,
466
+ description=f"Shadcn {name} component",
467
+ props=parsed.props,
468
+ astro_import=f"@/components/ui/{name}",
469
+ install_command=f"npx shadcn@latest add {name}",
470
+ requires_client_directive=parsed.interactivity != InteractivityMode.STATIC,
471
+ )
472
+
473
+
474
+ class SignatureIndex:
475
+ def __init__(
476
+ self,
477
+ settings: MapperSettings,
478
+ fetcher: RegistryFetcher,
479
+ mcp_client: OfficialMCPClient,
480
+ ):
481
+ self._index: dict[str, ComponentSignature] = {}
482
+ self._settings = settings
483
+ self._fetcher = fetcher
484
+ self._mcp = mcp_client
485
+ self._built = False
486
+
487
+ async def build(self) -> None:
488
+ """Build full index. Load from cache if fresh, else rebuild."""
489
+ cache_path = Path(self._settings.signature_index.index_cache_path)
490
+ if await self._load_from_cache(cache_path):
491
+ logger.info(
492
+ "Loaded signature index from cache (%d components)", len(self._index)
493
+ )
494
+ return
495
+ logger.info("Rebuilding signature index — this may take 5-15s on first run")
496
+ await self._rebuild()
497
+ await self._persist(cache_path)
498
+ self._built = True
499
+
500
+ async def _rebuild(self) -> None:
501
+ """Fetch all components, parse sources, populate index."""
502
+ # ── Official Shadcn components ────────────────────────────────────────
503
+ mcp_names = await self._mcp.list_components()
504
+ names = mcp_names if mcp_names else KNOWN_SHADCN_COMPONENTS
505
+ logger.info("Building signatures for %d Shadcn components", len(names))
506
+
507
+ source_map = await self._fetcher.fetch_many(names, RegistrySource.SHADCN)
508
+
509
+ for name in names:
510
+ try:
511
+ reg_data = source_map.get(name, {})
512
+ files = reg_data.get("files", [])
513
+ source_code = files[0].get("content", "") if files else ""
514
+
515
+ if source_code:
516
+ parsed = parse_source(source_code)
517
+ sig = _build_signature_from_parsed(name, parsed)
518
+ else:
519
+ hints = COMPONENT_HINTS.get(
520
+ name,
521
+ {"skeleton": "div", "root": "div", "compatible": [], "classes": []},
522
+ )
523
+ sig = _build_signature_from_hints(name, hints)
524
+
525
+ self._index[name] = sig
526
+ except Exception as exc:
527
+ logger.warning("Failed to build signature for %s: %s", name, exc)
528
+ hints = COMPONENT_HINTS.get(
529
+ name,
530
+ {"skeleton": "div", "root": "div", "compatible": [], "classes": []},
531
+ )
532
+ self._index[name] = _build_signature_from_hints(name, hints)
533
+
534
+ # ── External open-source registries ───────────────────────────────────
535
+ ext_regs = self._settings.registry.external_registries
536
+ if ext_regs:
537
+ logger.info(
538
+ "Fetching components from %d external registries", len(ext_regs)
539
+ )
540
+ ext_source_map = await self._fetcher.fetch_all_external(ext_regs)
541
+
542
+ for compound_name, reg_data in ext_source_map.items():
543
+ # compound_name = "bundui/pagination", "hextaui/pagination", etc.
544
+ try:
545
+ files = reg_data.get("files", [])
546
+ source_code = files[0].get("content", "") if files else ""
547
+
548
+ if source_code:
549
+ parsed = parse_source(source_code)
550
+ sig = _build_signature_from_parsed(compound_name, parsed)
551
+ else:
552
+ hints = COMPONENT_HINTS.get(
553
+ compound_name,
554
+ {"skeleton": "div", "root": "div", "compatible": [], "classes": []},
555
+ )
556
+ sig = _build_signature_from_hints(compound_name, hints)
557
+
558
+ self._index[compound_name] = sig
559
+ logger.debug("Indexed external component: %s", compound_name)
560
+ except Exception as exc:
561
+ logger.warning(
562
+ "Failed to build signature for external %s: %s",
563
+ compound_name, exc,
564
+ )
565
+ hints = COMPONENT_HINTS.get(
566
+ compound_name,
567
+ {"skeleton": "div", "root": "div", "compatible": [], "classes": []},
568
+ )
569
+ self._index[compound_name] = _build_signature_from_hints(
570
+ compound_name, hints
571
+ )
572
+
573
+ logger.info("Signature index built: %d components total", len(self._index))
574
+
575
+ async def _persist(self, cache_path: Path) -> None:
576
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
577
+ tmp = cache_path.with_suffix(".tmp")
578
+ data = {
579
+ "built_at": time.time(),
580
+ "components": {
581
+ k: v.model_dump(mode="json") for k, v in self._index.items()
582
+ },
583
+ }
584
+ tmp.write_text(json.dumps(data, indent=2))
585
+ tmp.replace(cache_path)
586
+ logger.debug("Persisted signature index to %s", cache_path)
587
+
588
+ async def _load_from_cache(self, cache_path: Path) -> bool:
589
+ if not cache_path.exists():
590
+ return False
591
+ try:
592
+ raw = json.loads(cache_path.read_text())
593
+ built_at = raw.get("built_at", 0)
594
+ ttl = self._settings.registry.http_cache_ttl_hours * 3600
595
+ if time.time() - built_at > ttl:
596
+ logger.debug("Signature cache expired")
597
+ return False
598
+ components = raw.get("components", {})
599
+ for name, data in components.items():
600
+ try:
601
+ self._index[name] = ComponentSignature.model_validate(data)
602
+ except Exception:
603
+ pass
604
+ return bool(self._index)
605
+ except Exception as exc:
606
+ logger.warning("Failed to load signature cache: %s", exc)
607
+ return False
608
+
609
+ def get_candidates(
610
+ self,
611
+ component_type: ComponentType,
612
+ normalized: NormalizedSegment,
613
+ fingerprint_hash: str,
614
+ ) -> list[RankedCandidate]:
615
+ """Get ranked candidates for a segment."""
616
+ cfg = self._settings.signature_index
617
+
618
+ # Get candidate names from type map
619
+ if component_type == ComponentType.UNKNOWN:
620
+ candidate_names = list(self._index.keys())
621
+ else:
622
+ candidate_names = list(TYPE_MAP.get(component_type, []))
623
+ # Fallback: if no type map entries, use all
624
+ if not candidate_names:
625
+ candidate_names = list(self._index.keys())
626
+
627
+ seg_skeleton = normalized.skeleton or ""
628
+ seg_class_tokens = set(normalized.class_tokens or [])
629
+
630
+ candidates: list[RankedCandidate] = []
631
+ for name in candidate_names:
632
+ sig = self._index.get(name)
633
+ if sig is None:
634
+ continue
635
+
636
+ struct_score = skeleton_similarity(seg_skeleton, sig.dom_skeleton)
637
+ class_score = jaccard_similarity(
638
+ seg_class_tokens, set(sig.structural_class_tokens)
639
+ )
640
+ type_score = (
641
+ 1.0 if component_type in sig.compatible_component_types else 0.3
642
+ )
643
+ comp = composite_score(struct_score, class_score, type_score)
644
+
645
+ candidates.append(
646
+ RankedCandidate(
647
+ component_name=name,
648
+ registry_source=sig.registry_source,
649
+ signature=sig,
650
+ structural_score=round(struct_score, 4),
651
+ type_score=round(type_score, 4),
652
+ class_token_score=round(class_score, 4),
653
+ composite_score=round(comp, 4),
654
+ )
655
+ )
656
+
657
+ return rank_candidates(
658
+ candidates,
659
+ top_k=cfg.max_candidates_per_segment,
660
+ min_threshold=cfg.candidate_min_threshold,
661
+ )
662
+
663
+ def batch_get_candidates(
664
+ self,
665
+ items: list[tuple[ComponentType, NormalizedSegment, str]],
666
+ ) -> dict[str, list[RankedCandidate]]:
667
+ """fingerprint_hash → ranked candidates for all items."""
668
+ result: dict[str, list[RankedCandidate]] = {}
669
+ for component_type, normalized, fingerprint_hash in items:
670
+ result[fingerprint_hash] = self.get_candidates(
671
+ component_type, normalized, fingerprint_hash
672
+ )
673
+ return result
674
+
675
+ def get_signature(self, name: str) -> ComponentSignature | None:
676
+ return self._index.get(name)
677
+
678
+ def get_all_component_names(self) -> list[str]:
679
+ return list(self._index.keys())
680
+
681
+ def register_custom(self, defn: CustomComponentDefinition) -> None:
682
+ """Add a custom component to the live index."""
683
+ from component_mapper.registry.custom_registry import CustomRegistry
684
+
685
+ cr = CustomRegistry()
686
+ sig = cr._to_signature(defn)
687
+ self._index[defn.name] = sig
688
+ logger.debug("Registered custom component in index: %s", defn.name)
689
+
690
+ def merge_custom(self, signatures: list[ComponentSignature]) -> None:
691
+ """Merge custom registry signatures into the index (priority: custom first)."""
692
+ for sig in signatures:
693
+ self._index[sig.component_name] = sig
694
+ logger.debug("Merged %d custom signatures into index", len(signatures))
File without changes