fix(ai): make role part-word guard position-sensitive

Follow-up on 5e2e6f9. The ROLE_PART_WORDS guard I added rejected any
name containing a part word anywhere, which misfired on "Icon Button":

  - button pattern matches
  - skipContainers=true
  - ROLE_PART_WORDS.test("icon button") is true (icon is a part word)
  - continue → fall through to /\bicon\b/ → returns 'icon' 

The intent of the guard is to catch "Card Header" / "Button Label" —
structural pieces named "<role> <part>". But "Icon Button" means "a
button OF THE ICON VARIETY", not "an icon inside a button". Word order
matters: part words are only piece-markers when they appear AFTER the
role word.

Switch the loop from pattern.test to pattern.exec so we know where the
role keyword sits, then only apply ROLE_PART_WORDS against the
substring AFTER the match. "Card Header" still skips (header after
card). "Icon Button" correctly returns 'button' (no part word after
button). Symmetric cases also work out: "Button Icon" falls through
card/button, hits the icon pattern and returns 'icon', which is the
right answer for "an icon frame inside a button context".

Inline coverage extended to 30 role cases including Icon/Primary/
Submit/Text/Image/Media modifier variants on both button and card.
This commit is contained in:
Fini 2026-04-06 19:14:53 +08:00
parent 5e2e6f9dae
commit d45f1a573b

View file

@ -146,17 +146,25 @@ function inferRoleFromName(node: PenNode): string | undefined {
// Pattern match
for (const [pattern, role, skipContainers] of NAME_PATTERN_MAP) {
if (pattern.test(lower)) {
// Use exec (not test) so we know WHERE in the name the role word sits.
// Position matters for the ROLE_PART_WORDS guard below.
const match = pattern.exec(lower);
if (!match) continue;
if (skipContainers) {
// Skip container-like names (e.g. "Button Group", "Buttons Row")
if (skipContainers) {
if (CONTAINER_SUFFIXES.test(lower)) continue;
// Also skip "Card Header", "Card Body", "Button Label", etc. — the
// role word is modified by a structural part word, meaning the
// node is a PIECE of the role, not the role itself.
if (ROLE_PART_WORDS.test(lower)) continue;
}
return role;
if (CONTAINER_SUFFIXES.test(lower)) continue;
// Skip "Card Header", "Card Body", "Button Label", etc. — when the
// part word appears AFTER the role word, the node is a PIECE of
// the role, not the role itself. Crucially, we only look at the
// text AFTER the match: "Icon Button" must still become 'button',
// because there the part word ("icon") appears BEFORE the role
// word and is just a modifier ("a button of the icon variety"),
// not an internal piece of the button.
const afterMatch = lower.slice(match.index + match[0].length);
if (ROLE_PART_WORDS.test(afterMatch)) continue;
}
return role;
}
return undefined;