fix(sema/verbs): validateEventName longest-prefix-match for multi-word verbs

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 23afeb6f86
commit a74f26728c

@ -167,6 +167,26 @@ describe('validateEventName', () => {
expect(validateEventName('commit-frob').family).toBe('commit') 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', () => { it('reports the existing dialog event names against the catalog', () => {
const dialogEvents = [ const dialogEvents = [
'open', 'open',

@ -156,6 +156,29 @@ const VERB_SET: ReadonlySet<string> = new Set(
Object.values(SEMA_VERBS).flatMap((verbs) => [...verbs]) 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<string>
): { 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? * 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 tail = dashIdx === -1 ? '' : name.slice(dashIdx + 1);
const variant = dashIdx === -1 ? undefined : tail; const variant = dashIdx === -1 ? undefined : tail;
// Case 1: head is a canonical verb → family from VERB_TO_FAMILY. // Case 1: name starts with a canonical verb (bare verb or verb-variant
if (VERB_SET.has(head)) { // 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 { return {
name, name,
head, head,
matchesCanonical: true, matchesCanonical: true,
variant, variant,
family: VERB_TO_FAMILY.get(head), family: VERB_TO_FAMILY.get(verbHeadMatch.verb),
verb: head verb: verbHeadMatch.verb
}; };
} }
// Case 2: head is a family name → tail's first segment should be a // Case 2: head is a family name → longest-prefix match against the
// verb in that family. // family's verbs (covers multi-word verbs like `shift.enter-mode`).
if (head in SEMA_VERBS) { if (head in SEMA_VERBS) {
const family = head as SemaFamilyName; 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 familyVerbs = SEMA_VERBS[family] as readonly string[];
const verbCanonical = verbCandidate.length > 0 && familyVerbs.includes(verbCandidate); const verbMatch = matchLongestVerb(tail, familyVerbs);
return { return {
name, name,
head, head,
matchesCanonical: verbCanonical, matchesCanonical: verbMatch !== undefined,
variant, variant,
family, family,
verb: verbCanonical ? verbCandidate : undefined verb: verbMatch?.verb
}; };
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.