From a74f26728c59f9d9daf15fbdb59d3a417aa03af6 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 25 May 2026 16:48:17 +0200 Subject: [PATCH] fix(sema/verbs): validateEventName longest-prefix-match for multi-word verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canon includes two multi-word shift verbs: `enter-mode` and `exit-mode`. `validateEventName` was splitting at the first dash and checking the canon for `enter` / `exit` alone — those aren't canon, so legitimately canonical events like `shift-enter-mode` were being flagged as drift. Fixed by matching the longest canonical verb prefix against the tail (family-verb shape) or against the name (bare verb shape). Handles trailing variants too — `shift-enter-mode-edit` recognises `enter-mode` as the verb and `edit` as the variant. Verification: - 1 new test covering multi-word verb shapes; 19/19 pass - `npm run morfo:vocabulary`: 58 → 57 warns (editable.shift-enter-mode no longer falsely flagged), EXIT 0 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/sema/verbs.test.ts | 20 +++++++++++++++++ src/uix/sema/verbs.ts | 46 +++++++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/uix/sema/verbs.test.ts b/src/uix/sema/verbs.test.ts index dc606ecc8..c4ee63683 100644 --- a/src/uix/sema/verbs.test.ts +++ b/src/uix/sema/verbs.test.ts @@ -167,6 +167,26 @@ describe('validateEventName', () => { expect(validateEventName('commit-frob').family).toBe('commit') }) + it('recognises multi-word canonical verbs (enter-mode, exit-mode)', () => { + // Family-verb shape with multi-word verb. + expect(validateEventName('shift-enter-mode')).toEqual({ + name: 'shift-enter-mode', + head: 'shift', + matchesCanonical: true, + variant: 'enter-mode', + family: 'shift', + verb: 'enter-mode' + }) + expect(validateEventName('shift-exit-mode').matchesCanonical).toBe(true) + // With trailing variant. + expect(validateEventName('shift-enter-mode-edit').matchesCanonical).toBe(true) + expect(validateEventName('shift-enter-mode-edit').verb).toBe('enter-mode') + // Bare verb shape — `enter-mode` alone is canonical. + expect(validateEventName('enter-mode').matchesCanonical).toBe(true) + expect(validateEventName('enter-mode').verb).toBe('enter-mode') + expect(validateEventName('enter-mode').family).toBe('shift') + }) + it('reports the existing dialog event names against the catalog', () => { const dialogEvents = [ 'open', diff --git a/src/uix/sema/verbs.ts b/src/uix/sema/verbs.ts index ddf082ead..3c808b274 100644 --- a/src/uix/sema/verbs.ts +++ b/src/uix/sema/verbs.ts @@ -156,6 +156,29 @@ const VERB_SET: ReadonlySet = new Set( Object.values(SEMA_VERBS).flatMap((verbs) => [...verbs]) ); +/** + * Find the longest canonical verb that is a prefix of `text` (followed + * by end-of-string or a dash). Required because the canon includes + * multi-word verbs (`enter-mode`, `exit-mode`); a naive "split at first + * dash" would only see `enter` / `exit` and miss the actual verb. + * + * Returns the matched verb plus whatever remains of `text` after it + * (with the separating dash consumed), or `undefined` if no verb + * prefixes `text`. Sorts by length descending so `enter-mode` beats + * `enter` when both could match. + */ +function matchLongestVerb( + text: string, + verbs: Iterable +): { verb: string; tail: string } | undefined { + const sorted = [...verbs].sort((a, b) => b.length - a.length); + for (const verb of sorted) { + if (text === verb) return { verb, tail: '' }; + if (text.startsWith(verb + '-')) return { verb, tail: text.slice(verb.length + 1) }; + } + return undefined; +} + /** * Reverse lookup: which family does a given verb belong to? * @@ -221,33 +244,34 @@ export function validateEventName(name: string): VerbValidation { const tail = dashIdx === -1 ? '' : name.slice(dashIdx + 1); const variant = dashIdx === -1 ? undefined : tail; - // Case 1: head is a canonical verb → family from VERB_TO_FAMILY. - if (VERB_SET.has(head)) { + // Case 1: name starts with a canonical verb (bare verb or verb-variant + // shape). Longest-prefix match against ALL canonical verbs so + // multi-word verbs like `enter-mode` win over `enter`. + const verbHeadMatch = matchLongestVerb(name, VERB_SET); + if (verbHeadMatch) { return { name, head, matchesCanonical: true, variant, - family: VERB_TO_FAMILY.get(head), - verb: head + family: VERB_TO_FAMILY.get(verbHeadMatch.verb), + verb: verbHeadMatch.verb }; } - // Case 2: head is a family name → tail's first segment should be a - // verb in that family. + // Case 2: head is a family name → longest-prefix match against the + // family's verbs (covers multi-word verbs like `shift.enter-mode`). if (head in SEMA_VERBS) { const family = head as SemaFamilyName; - const tailFirstDash = tail.indexOf('-'); - const verbCandidate = tailFirstDash === -1 ? tail : tail.slice(0, tailFirstDash); const familyVerbs = SEMA_VERBS[family] as readonly string[]; - const verbCanonical = verbCandidate.length > 0 && familyVerbs.includes(verbCandidate); + const verbMatch = matchLongestVerb(tail, familyVerbs); return { name, head, - matchesCanonical: verbCanonical, + matchesCanonical: verbMatch !== undefined, variant, family, - verb: verbCanonical ? verbCandidate : undefined + verb: verbMatch?.verb }; }