chore: archive tools/ui_inspector snapshot before migration removal.

Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 11:19:14 +08:00
parent 46bc573777
commit 633ecff4e2
92 changed files with 13704 additions and 288 deletions

View File

@@ -0,0 +1,13 @@
"""Locator compilers + validators + element catalog."""
from .browser import compile_browser_locators
from .catalog import ElementCatalogService
from .validator import validate_locator_against_rows
from .windows import compile_windows_locators
__all__ = [
"compile_browser_locators",
"compile_windows_locators",
"validate_locator_against_rows",
"ElementCatalogService",
]

View File

@@ -0,0 +1,74 @@
"""Shared locator compiler helpers."""
from __future__ import annotations
from typing import Any, Optional
from models.locator import LocatorCandidate
def rank_and_select_primary(
candidates: list[LocatorCandidate],
) -> tuple[Optional[LocatorCandidate], list[LocatorCandidate]]:
"""
Pick production primary only if unique and not coordinate-based.
Fallback list excludes primary.
"""
ranked = sorted(
candidates,
key=lambda c: (
0 if c.production_eligible else 1,
0 if c.unique else 1,
0 if c.uses_coordinates else 0,
1 if c.uses_coordinates else 0,
1 if c.uses_index else 0,
-float(c.stability_score or 0.0),
),
)
primary: Optional[LocatorCandidate] = None
for c in ranked:
if c.production_eligible and not c.uses_coordinates:
primary = c
break
fallbacks = [c for c in ranked if c is not primary]
return primary, fallbacks
def count_matches(
rows: list[dict[str, Any]],
*,
properties: dict[str, Any],
scope: Optional[dict[str, Any]] = None,
) -> int:
"""Count rows matching structured properties (snapshot / live tree)."""
props = {k: v for k, v in (properties or {}).items() if v not in (None, "")}
if not props:
return 0
scope = scope or {}
n = 0
for r in rows or []:
if not isinstance(r, dict):
continue
if scope.get("window_class"):
# optional soft filter if row carries window info
rc = r.get("window_class") or r.get("window_class_name")
if rc and rc != scope.get("window_class"):
continue
ok = True
for k, v in props.items():
if k == "control_type":
rv = r.get("control_type") or r.get("role") or ""
elif k == "role":
rv = r.get("role") or r.get("control_type") or ""
elif k == "text":
rv = r.get("text") or r.get("value") or r.get("name") or ""
elif k == "test_id":
rv = r.get("test_id") or r.get("automation_id") or ""
else:
rv = r.get(k)
if str(rv or "") != str(v):
ok = False
break
if ok:
n += 1
return n

View File

@@ -0,0 +1,190 @@
"""Browser locator compiler (role / test id / label / placeholder / text / css id)."""
from __future__ import annotations
from typing import Any, Optional
from models.locator import LocatorCandidate
from .base import count_matches, rank_and_select_primary
def _expr(strategy: str, props: dict[str, Any]) -> str:
parts = [f"{k}={v!r}" for k, v in props.items() if v not in (None, "")]
return f"{strategy}:" + ",".join(parts)
def compile_browser_locators(
element: dict[str, Any],
*,
rows: Optional[list[dict[str, Any]]] = None,
frame: Optional[dict[str, Any]] = None,
) -> list[LocatorCandidate]:
"""
Generate ordered Playwright-oriented locator candidates.
Priority roughly: role+name → test id → label → placeholder → text → css id → scoped.
Coordinates are never emitted as production candidates.
"""
el = element or {}
rows = list(rows or [])
scope = {"frame_url": (frame or {}).get("url") or "", "frame_name": (frame or {}).get("name") or ""}
cands: list[LocatorCandidate] = []
role = str(el.get("role") or el.get("control_type") or "").strip()
name = str(el.get("name") or "").strip()
text = str(el.get("text") or el.get("value") or "").strip()
is_editable = bool(el.get("contenteditable"))
if is_editable:
# Draft.js/contenteditable often exposes current user text as its
# accessible name. That value is payload, never element identity.
current_value = str(el.get("value") or "").strip()
if name == current_value or name == text:
name = ""
text = ""
test_id = str(el.get("test_id") or el.get("data_testid") or el.get("automation_id") or "").strip()
label = str(el.get("label") or "").strip()
placeholder = str(el.get("placeholder") or "").strip()
name_attr = str(el.get("name_attr") or "").strip()
href = str(el.get("href") or "").strip()
css_id = str(el.get("id") or el.get("css_id") or "").strip()
css_path = str(el.get("css") or el.get("css_path") or "").strip()
stable_css = str(el.get("stable_css") or "").strip()
semantic_role = str(el.get("semantic_role") or "").strip()
semantic_name = str(el.get("semantic_name") or "").strip()
# Prefer existing primary_locator from page_dump if present
existing = el.get("primary_locator") or {}
if isinstance(existing, dict) and existing.get("expr"):
props = dict(existing.get("properties") or {})
if not props and existing.get("strategy"):
props = {"expr": existing.get("expr")}
c = LocatorCandidate(
strategy=str(existing.get("strategy") or "primary"),
scope=scope,
properties=props or {"expression": existing.get("expr")},
expression=str(existing.get("expr") or ""),
stability_score=0.85,
reasons=["from_page_dump_primary_locator"],
)
if rows and props:
c.mark_counts(count_matches(rows, properties=props, scope=scope))
c.validation = "snapshot_only"
elif rows and existing.get("expr"):
# Cannot count without structured props — leave unique unknown
c.validation = "snapshot_only"
cands.append(c)
for pw in el.get("playwright_candidates") or []:
if not isinstance(pw, dict):
continue
props = dict(pw.get("properties") or {})
expr = str(pw.get("expr") or pw.get("expression") or "")
strat = str(pw.get("strategy") or "playwright")
c = LocatorCandidate(
strategy=strat,
scope=scope,
properties=props or {"expression": expr},
expression=expr,
stability_score=float(pw.get("score") or 0.7),
uses_index=bool(pw.get("uses_index")),
uses_coordinates=bool(pw.get("uses_coordinates")),
reasons=["from_playwright_candidates"],
)
if rows and props:
c.mark_counts(count_matches(rows, properties=props, scope=scope))
c.validation = "snapshot_only"
cands.append(c)
def add(strategy: str, props: dict[str, Any], score: float, reason: str) -> None:
if not props or not any(str(v).strip() for v in props.values()):
return
c = LocatorCandidate(
strategy=strategy,
scope=dict(scope),
properties=dict(props),
expression=_expr(strategy, props),
stability_score=score,
reasons=[reason],
)
if rows:
c.mark_counts(count_matches(rows, properties=props, scope=scope))
c.validation = "snapshot_only"
cands.append(c)
if role and name:
add("role", {"role": role, "name": name}, 0.95, "role_name")
elif role and is_editable:
add("role", {"role": role}, 0.78, "editable_role")
if semantic_role and semantic_name:
add(
"semantic_role",
{"role": semantic_role, "name": semantic_name},
0.86,
"semantic_target_inferred_from_form_scope",
)
if test_id:
add("test_id", {"test_id": test_id}, 0.93, "test_id")
if label:
add("label", {"label": label}, 0.88, "label")
if placeholder:
add("placeholder", {"placeholder": placeholder}, 0.84, "placeholder")
if name_attr:
add("name_attr", {"name_attr": name_attr}, 0.89, "html_name_attribute")
if href and role.lower() == "link":
add("href", {"href": href}, 0.91, "link_destination")
if text and len(text) <= 80:
add("text", {"text": text}, 0.75, "visible_text")
if css_id and not css_id[0].isdigit():
add("css_id", {"id": css_id}, 0.8, "css_id")
if stable_css:
c = LocatorCandidate(
strategy="stable_css",
scope=dict(scope),
properties={"stable_css": stable_css},
expression=f"css:{stable_css}",
stability_score=0.78 if ("#" in stable_css or "data-testid" in stable_css) else 0.62,
reasons=["stable_attribute_selector", "live_unique_required"],
)
c.production_eligible = False
cands.append(c)
if css_path:
# Full/scoped CSS is a last-resort executable locator. It only becomes
# production eligible after live validation proves it is unique.
c = LocatorCandidate(
strategy="css",
scope=dict(scope),
properties={"css": css_path},
expression=f"css:{css_path}",
stability_score=0.48 if "#" in css_path else 0.32,
reasons=["event_dom_path", "live_unique_required"],
)
c.production_eligible = False
cands.append(c)
# Never add coordinate locator as candidate for production
if el.get("bounds") or el.get("rect"):
c = LocatorCandidate(
strategy="bounding_rect",
scope=dict(scope),
properties={"bounds": el.get("bounds") or el.get("rect")},
expression="coordinates:(evidence_only)",
stability_score=0.05,
uses_coordinates=True,
production_eligible=False,
reasons=["coordinates_evidence_only"],
)
c.unique = False
c.ambiguous = True
cands.append(c)
# Re-validate production_eligible after counts
for c in cands:
if c.actual_count is not None:
c.mark_counts(int(c.actual_count), expected=int(c.expected_count or 1))
elif c.uses_coordinates:
c.production_eligible = False
primary, _ = rank_and_select_primary(cands)
# Ensure ambiguous never becomes primary via side effect — rank handles it.
del primary
return cands

View File

@@ -0,0 +1,294 @@
"""Element Catalog: merge observations, locator history, parameterization."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Optional
from models.element import ElementCatalog, ElementRef, element_fingerprint
from models.locator import LocatorCandidate
from models.serialize import to_dict, write_model_json
from redaction import redact_element_fields
from core.policies.element_identity import element_identity_key
from .base import rank_and_select_primary
from .browser import compile_browser_locators
from .validator import validate_locator_against_rows
from .windows import compile_windows_locators
_DYNAMIC_VALUE_HINTS = re.compile(
r"(联系人|搜索|search|contact|user|chat|title|name)",
re.I,
)
def suggest_logical_name(element: dict[str, Any]) -> str:
aid = str(element.get("automation_id") or "").strip()
name = str(element.get("name") or "").strip()
role = str(element.get("control_type") or element.get("role") or "el").strip()
text = str(element.get("text") or "").strip()
semantic_name = str(element.get("semantic_name") or "").strip()
if element.get("contenteditable"):
base = aid or str(element.get("placeholder") or element.get("label") or "") or role
else:
base = aid or semantic_name or name or (text if 0 < len(text) <= 48 else "")
if not base:
for ancestor in element.get("parent_chain") or []:
ancestor_id = str((ancestor or {}).get("id") or "").strip()
ancestor_text = str((ancestor or {}).get("text") or "").strip()
if ancestor_id:
base = f"{ancestor_id}_{role}"
break
if ancestor_text and len(ancestor_text) <= 32:
base = f"{ancestor_text}_{role}"
break
base = base or role
base = re.sub(r"[^\w\u4e00-\u9fff]+", "_", base, flags=re.UNICODE).strip("_")
base = base[:48] or "element"
return base.lower() if base.isascii() else base
def _maybe_parameterize(value: str, *, field: str) -> tuple[str, dict[str, str]]:
"""Replace likely dynamic business values with ${param} placeholders."""
v = (value or "").strip()
if not v or len(v) > 64:
return v, {}
if field in {"value", "text", "name"} and _DYNAMIC_VALUE_HINTS.search(field):
return "${value}", {"value": v}
# Contact-like short Chinese/ASCII names in value when name is generic
if field == "value" and 1 < len(v) <= 32 and not v.startswith("${"):
# Only parameterize when looks like a person/search term (letters/CJK, not chrome)
if re.fullmatch(r"[\w\u4e00-\u9fff \-_.@]+", v, flags=re.UNICODE):
chrome = {"关闭", "最小化", "最大化", "取消", "确定", "发送", "搜索"}
if v not in chrome:
return "${contact_name}", {"contact_name": v}
return v, {}
class ElementCatalogService:
def __init__(self, flow_dir: Path, *, flow_id: str = "") -> None:
self.flow_dir = Path(flow_dir)
self.elements_dir = self.flow_dir / "elements"
self.elements_dir.mkdir(parents=True, exist_ok=True)
self.catalog_path = self.flow_dir / "element_catalog.json"
self.catalog = ElementCatalog(flow_id=flow_id)
self._by_fp: dict[str, ElementRef] = {}
self._by_identity: dict[str, ElementRef] = {}
self._by_name: dict[str, ElementRef] = {}
def load(self) -> None:
if not self.catalog_path.exists():
return
import json
from models.serialize import from_dict
data = json.loads(self.catalog_path.read_text(encoding="utf-8"))
self.catalog.flow_id = data.get("flow_id") or self.catalog.flow_id
for item in data.get("elements") or []:
# Reconstruct LocatorCandidate lists lightly
locs = []
for lc in item.get("locator_candidates") or []:
locs.append(from_dict(LocatorCandidate, lc))
primary = None
if item.get("primary_locator"):
primary = from_dict(LocatorCandidate, item["primary_locator"])
fallbacks = [
from_dict(LocatorCandidate, x) for x in (item.get("fallback_locators") or [])
]
el = ElementRef(
id=item.get("id") or "",
logical_name=item.get("logical_name") or "",
surface=item.get("surface") or "windows",
backend=item.get("backend") or "",
app=item.get("app") or {},
window_scope=item.get("window_scope") or {},
element_scope=item.get("element_scope") or {},
role=item.get("role") or "",
control_type=item.get("control_type") or "",
name=item.get("name") or "",
value=item.get("value") or "",
automation_id=item.get("automation_id") or "",
class_name=item.get("class_name") or "",
framework_id=item.get("framework_id") or "",
states=item.get("states") or {},
bounds=item.get("bounds") or {},
actions=list(item.get("actions") or []),
parent_chain=list(item.get("parent_chain") or []),
raw=item.get("raw") or {},
fingerprint=item.get("fingerprint") or "",
sensitive=bool(item.get("sensitive")),
locator_candidates=locs,
primary_locator=primary,
fallback_locators=fallbacks,
expected_count=int(item.get("expected_count") or 1),
verified_count=item.get("verified_count"),
verification_level=item.get("verification_level") or "none",
stability_score=float(item.get("stability_score") or 0.0),
verified_at=item.get("verified_at"),
first_seen=item.get("first_seen") or "",
last_seen=item.get("last_seen") or "",
observation_count=int(item.get("observation_count") or 1),
locator_history=list(item.get("locator_history") or []),
parameterized=dict(item.get("parameterized") or {}),
)
if el.fingerprint:
self._by_fp[el.fingerprint] = el
identity = str((el.raw or {}).get("_identity_key") or "")
if not identity:
identity = element_identity_key(
el.raw or to_dict(el),
surface=el.surface,
page_url=str((el.element_scope or {}).get("page_url") or ""),
frame_name=str((el.element_scope or {}).get("frame_name") or ""),
)
self._by_identity[identity] = el
if el.logical_name:
self._by_name[el.logical_name] = el
self.catalog.elements.append(el)
def save(self) -> None:
from datetime import datetime
self.catalog.updated_at = datetime.now().astimezone().isoformat()
write_model_json(self.catalog_path, self.catalog)
for el in self.catalog.elements:
write_model_json(self.elements_dir / f"{el.id}.json", el)
def upsert_from_snapshot(
self,
element: dict[str, Any],
*,
surface: str,
backend: str = "",
window_scope: Optional[dict[str, Any]] = None,
rows: Optional[list[dict[str, Any]]] = None,
logical_name: Optional[str] = None,
frame: Optional[dict[str, Any]] = None,
live_counts: Optional[dict[str, int]] = None,
) -> ElementRef:
cleaned, sensitive = redact_element_fields(dict(element or {}))
win = dict(window_scope or {})
value = str(cleaned.get("value") or "")
is_editable = bool(cleaned.get("contenteditable"))
param_value, params = _maybe_parameterize(value, field="value")
if params:
cleaned["value"] = param_value
fp = element_fingerprint(
surface=surface,
role=str(cleaned.get("control_type") or cleaned.get("role") or ""),
name=str(cleaned.get("name") or ""),
automation_id=str(cleaned.get("automation_id") or ""),
class_name=str(cleaned.get("class_name") or ""),
value=str(cleaned.get("value") or ""),
window_scope=win,
dom_fingerprint=str(cleaned.get("fingerprint") or cleaned.get("css") or ""),
page_scope=str((frame or {}).get("url") or win.get("url") or ""),
contenteditable=is_editable,
)
identity = element_identity_key(
cleaned,
surface=surface,
page_url=str((frame or {}).get("url") or win.get("url") or ""),
frame_name=str((frame or {}).get("name") or ""),
)
cleaned["_identity_key"] = identity
existing = self._by_identity.get(identity) or self._by_fp.get(fp)
if existing is None and logical_name and logical_name in self._by_name:
existing = self._by_name[logical_name]
if surface == "browser":
cands = compile_browser_locators(cleaned, rows=rows, frame=frame)
else:
cands = compile_windows_locators(cleaned, rows=rows, window_scope=win)
for c in cands:
if rows is not None and not c.uses_coordinates:
validate_locator_against_rows(c, rows, level="snapshot_only")
if live_counts is not None and c.strategy in live_counts:
c.mark_counts(int(live_counts[c.strategy]))
c.validation = "live"
primary, fallbacks = rank_and_select_primary(cands)
# Ambiguous / coordinates never primary
if primary is not None and (primary.ambiguous or not primary.production_eligible):
primary = None
lname = logical_name or (existing.logical_name if existing else "") or suggest_logical_name(
cleaned
)
if existing:
# Merge observation; keep locator history
if primary is not None:
old = existing.primary_locator
if old is not None and to_dict(old) != to_dict(primary):
existing.locator_history.append(
{"replaced_at": existing.last_seen, "locator": to_dict(old)}
)
existing.primary_locator = primary
existing.locator_candidates = cands
existing.fallback_locators = fallbacks
existing.touch()
existing.raw = cleaned
existing.bounds = dict(cleaned.get("bounds") or cleaned.get("rect") or existing.bounds or {})
if params:
existing.parameterized.update(params)
existing.sensitive = existing.sensitive or sensitive
existing.verification_level = "live" if live_counts else "snapshot_only"
self._by_fp[fp] = existing
self._by_identity[identity] = existing
if primary and primary.actual_count is not None:
existing.verified_count = primary.actual_count
self.save()
return existing
el = ElementRef(
logical_name=lname,
surface=surface,
backend=backend,
window_scope=win,
element_scope={
"page_url": str((frame or {}).get("url") or win.get("url") or ""),
"frame_name": str((frame or {}).get("name") or ""),
},
role=str(cleaned.get("role") or ""),
control_type=str(cleaned.get("control_type") or ""),
name=str(cleaned.get("name") or ""),
value=str(cleaned.get("value") or ""),
automation_id=str(cleaned.get("automation_id") or ""),
class_name=str(cleaned.get("class_name") or ""),
framework_id=str(cleaned.get("framework_id") or ""),
states={
"is_enabled": cleaned.get("is_enabled"),
"is_offscreen": cleaned.get("is_offscreen"),
},
bounds=dict(cleaned.get("bounds") or cleaned.get("rect") or {}),
parent_chain=list(cleaned.get("parent_chain") or []),
raw=cleaned,
fingerprint=fp,
sensitive=sensitive,
locator_candidates=cands,
primary_locator=primary,
fallback_locators=fallbacks,
verification_level="live" if live_counts else "snapshot_only",
verified_count=(primary.actual_count if primary else None),
parameterized=params,
stability_score=float(primary.stability_score) if primary else 0.0,
)
# Ensure unique logical_name
base = el.logical_name
i = 2
while el.logical_name in self._by_name:
el.logical_name = f"{base}_{i}"
i += 1
self.catalog.elements.append(el)
self._by_fp[fp] = el
self._by_identity[identity] = el
self._by_name[el.logical_name] = el
self.save()
return el

View File

@@ -0,0 +1,50 @@
"""Locator uniqueness validation against snapshot / live tree rows."""
from __future__ import annotations
from typing import Any, Optional
from models.locator import LocatorCandidate
from .base import count_matches
def validate_locator_against_rows(
candidate: LocatorCandidate,
rows: list[dict[str, Any]],
*,
level: str = "snapshot_only",
expected_count: int = 1,
) -> LocatorCandidate:
"""
Validate structured locator properties against a row list.
Levels supported in this phase: snapshot_only | live_tree
"""
actual = count_matches(
rows,
properties=candidate.properties,
scope=candidate.scope,
)
candidate.mark_counts(actual, expected=expected_count)
candidate.validation = level
if not candidate.unique:
candidate.reasons = list(candidate.reasons or [])
if "ambiguous_match_count" not in candidate.reasons:
candidate.reasons.append("ambiguous_match_count")
candidate.production_eligible = False
if candidate.uses_coordinates:
candidate.production_eligible = False
if "coordinates_never_primary" not in (candidate.reasons or []):
candidate.reasons = list(candidate.reasons or []) + ["coordinates_never_primary"]
return candidate
def choose_production_primary(
candidates: list[LocatorCandidate],
) -> Optional[LocatorCandidate]:
"""Return first production-eligible unique non-coordinate locator, else None."""
for c in candidates:
if c.production_eligible and c.unique and not c.uses_coordinates:
return c
return None

View File

@@ -0,0 +1,180 @@
"""Windows UIA locator compiler (Name ≠ Value; no coordinate primary)."""
from __future__ import annotations
from typing import Any, Optional
from models.locator import LocatorCandidate
from .base import count_matches, rank_and_select_primary
def _expr(strategy: str, props: dict[str, Any]) -> str:
parts = [f"{k}={v!r}" for k, v in props.items() if v not in (None, "")]
return f"{strategy}:" + ",".join(parts)
def compile_windows_locators(
element: dict[str, Any],
*,
rows: Optional[list[dict[str, Any]]] = None,
window_scope: Optional[dict[str, Any]] = None,
) -> list[LocatorCandidate]:
"""
Priority:
1. Window scope + AutomationId + ControlType
2. Window scope + stable Name + ControlType
3. Window scope + Name + Value + ControlType
4. Business parent + child attrs
5. Visible Value + ControlType
6. Relative ancestor scope
7. Index with business reason (low score)
8. BoundingRectangle evidence only
"""
el = element or {}
rows = list(rows or [])
win = dict(window_scope or el.get("window_scope") or {})
scope = {
"window_class": win.get("class_name") or win.get("window_class") or "",
"window_title": win.get("title") or win.get("window_title") or "",
"process_name": win.get("process_name") or "",
}
cands: list[LocatorCandidate] = []
aid = str(el.get("automation_id") or "").strip()
name = str(el.get("name") or "").strip()
value = str(el.get("value") or "").strip()
ctype = str(el.get("control_type") or el.get("role") or "").strip()
class_name = str(el.get("class_name") or "").strip()
parent_chain = list(el.get("parent_chain") or [])
def add(
strategy: str,
props: dict[str, Any],
score: float,
reason: str,
*,
uses_index: bool = False,
uses_coordinates: bool = False,
) -> None:
if not props or not any(str(v).strip() for v in props.values() if not isinstance(v, (dict, list))):
return
c = LocatorCandidate(
strategy=strategy,
scope=dict(scope),
properties=dict(props),
expression=_expr(strategy, props),
stability_score=score,
uses_index=uses_index,
uses_coordinates=uses_coordinates,
reasons=[reason],
)
if rows:
c.mark_counts(count_matches(rows, properties=props, scope=scope))
c.validation = "snapshot_only"
elif uses_coordinates:
c.production_eligible = False
c.unique = False
c.ambiguous = True
cands.append(c)
if aid and ctype:
add(
"window_automation_id_type",
{"automation_id": aid, "control_type": ctype},
0.96,
"automation_id_control_type",
)
elif aid:
add("window_automation_id", {"automation_id": aid}, 0.9, "automation_id")
if name and ctype and name.lower() not in {"item", "pane", "unknown"}:
add(
"window_name_type",
{"name": name, "control_type": ctype},
0.88,
"stable_name_control_type",
)
if name and value and ctype:
add(
"window_name_value_type",
{"name": name, "value": value, "control_type": ctype},
0.86,
"name_value_control_type",
)
# Business parent + child
if parent_chain:
parent = parent_chain[0] if isinstance(parent_chain[0], dict) else {}
parent_name = str(parent.get("name") or parent.get("automation_id") or "").strip()
if parent_name and (aid or name or value):
props: dict[str, Any] = {"parent_name": parent_name}
if aid:
props["automation_id"] = aid
if name:
props["name"] = name
if value:
props["value"] = value
if ctype:
props["control_type"] = ctype
add("parent_scoped", props, 0.82, "business_parent_scope")
if value and ctype and len(value) <= 64:
add(
"value_type",
{"value": value, "control_type": ctype},
0.78,
"visible_value_control_type",
)
if class_name and ctype:
add(
"class_type",
{"class_name": class_name, "control_type": ctype},
0.55,
"class_name_control_type",
)
# Index only with explicit business reason — low score, not auto primary
idx = el.get("index")
if idx is not None and (aid or name):
props = {"index": int(idx)}
if aid:
props["automation_id"] = aid
if name:
props["name"] = name
if ctype:
props["control_type"] = ctype
add(
"indexed",
props,
0.35,
"index_requires_business_justification",
uses_index=True,
)
bounds = el.get("bounds") or el.get("rect") or el.get("bounding_rectangle")
if bounds:
c = LocatorCandidate(
strategy="bounding_rectangle",
scope=dict(scope),
properties={"bounds": bounds},
expression="coordinates:(evidence_only)",
stability_score=0.05,
uses_coordinates=True,
production_eligible=False,
unique=False,
ambiguous=True,
reasons=["coordinates_never_primary"],
)
cands.append(c)
for c in cands:
if c.actual_count is not None:
c.mark_counts(int(c.actual_count), expected=int(c.expected_count or 1))
if c.uses_coordinates:
c.production_eligible = False
rank_and_select_primary(cands)
return cands