@lokascript/i18n 2.7.2 → 2.8.0

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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/dist/browser.cjs +649 -180
  3. package/dist/browser.cjs.map +1 -1
  4. package/dist/browser.d.cts +2 -2
  5. package/dist/browser.d.ts +2 -2
  6. package/dist/browser.js +649 -180
  7. package/dist/browser.js.map +1 -1
  8. package/dist/dictionaries/index.cjs +370 -174
  9. package/dist/dictionaries/index.cjs.map +1 -1
  10. package/dist/dictionaries/index.js +370 -174
  11. package/dist/dictionaries/index.js.map +1 -1
  12. package/dist/index.cjs +649 -180
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +35 -35
  15. package/dist/index.d.ts +35 -35
  16. package/dist/index.js +649 -180
  17. package/dist/index.js.map +1 -1
  18. package/dist/lokascript-i18n.min.js +1 -1
  19. package/dist/lokascript-i18n.min.js.map +1 -1
  20. package/dist/lokascript-i18n.mjs +678 -175
  21. package/dist/lokascript-i18n.mjs.map +1 -1
  22. package/dist/plugins/vite.cjs +370 -174
  23. package/dist/plugins/vite.cjs.map +1 -1
  24. package/dist/plugins/vite.js +370 -174
  25. package/dist/plugins/vite.js.map +1 -1
  26. package/dist/plugins/webpack.cjs +370 -174
  27. package/dist/plugins/webpack.cjs.map +1 -1
  28. package/dist/plugins/webpack.js +370 -174
  29. package/dist/plugins/webpack.js.map +1 -1
  30. package/dist/{transformer-DM7iplCU.d.cts → transformer--W46PGaW.d.cts} +8 -0
  31. package/dist/{transformer-BA8YX9u1.d.ts → transformer-FU06J6kG.d.ts} +8 -0
  32. package/package.json +7 -9
  33. package/src/dictionaries/ar.ts +20 -15
  34. package/src/dictionaries/bn.ts +10 -5
  35. package/src/dictionaries/de.ts +20 -8
  36. package/src/dictionaries/es.ts +10 -5
  37. package/src/dictionaries/fr.ts +15 -6
  38. package/src/dictionaries/he.ts +4 -0
  39. package/src/dictionaries/hi.ts +3 -2
  40. package/src/dictionaries/id.ts +31 -16
  41. package/src/dictionaries/it.ts +11 -4
  42. package/src/dictionaries/ja.ts +23 -15
  43. package/src/dictionaries/ko.ts +10 -5
  44. package/src/dictionaries/ms.ts +4 -1
  45. package/src/dictionaries/pl.ts +12 -5
  46. package/src/dictionaries/pt.ts +10 -5
  47. package/src/dictionaries/qu.ts +30 -19
  48. package/src/dictionaries/ru.ts +5 -2
  49. package/src/dictionaries/sw.ts +29 -14
  50. package/src/dictionaries/th.ts +5 -2
  51. package/src/dictionaries/tl.ts +4 -1
  52. package/src/dictionaries/tr.ts +24 -16
  53. package/src/dictionaries/uk.ts +5 -2
  54. package/src/dictionaries/vi.ts +23 -17
  55. package/src/dictionaries/zh.ts +24 -9
  56. package/src/grammar/grammar.test.ts +152 -0
  57. package/src/grammar/profiles/index.ts +316 -1
  58. package/src/grammar/transformer.ts +64 -1
  59. package/src/new-languages.test.ts +9 -3
  60. package/src/positional-keyword-drift.test.ts +6 -19
@@ -2510,3 +2510,155 @@ describe('qu fused (no-underscore) empty/null value word (chusaq)', () => {
2510
2510
  expect(out.indexOf('chusaq')).toBeLessThan(out.indexOf('.error'));
2511
2511
  });
2512
2512
  });
2513
+
2514
+ // =============================================================================
2515
+ // SOV reorder stranding (transformer-rendering arc)
2516
+ // =============================================================================
2517
+ //
2518
+ // Three rendering defects, one family (docs-internal/HANDOFF_transformer-rendering.md):
2519
+ // (a) `wait for X or Y from Z` rendered verb-first (tr) / verb-medial (qu) —
2520
+ // reorderRoles' safety-net appended roles missing from canonicalOrder
2521
+ // AFTER the verb, stranding the or-run/from-phrase outside the clause;
2522
+ // (b) `repeat until event E from S` postposed the from-phrase after the verb;
2523
+ // (c) a trailing block terminator (`… wait 200ms end` fragments from the
2524
+ // then-splitter) was swept into the open role's VALUE and rendered inside
2525
+ // the phrase instead of after the verb.
2526
+ // Fixed by extending tr/qu/bn canonicalOrder to full verb-final role orders
2527
+ // and stripping/re-appending the stranded terminator in transformSingle.
2528
+
2529
+ describe('SOV reorder stranding (transformer-rendering arc)', () => {
2530
+ it('[tr] wait + or-run + from-phrase renders verb-final, from-phrase in-clause', () => {
2531
+ const out = new GrammarTransformer('en', 'tr').transform(
2532
+ 'wait for pointermove(clientY) or pointerup(clientY) from document'
2533
+ );
2534
+ expect(out).toContain('veya');
2535
+ expect(out.trim().endsWith('bekle')).toBe(true);
2536
+ expect(out.indexOf('belge den')).toBeGreaterThanOrEqual(0);
2537
+ expect(out.indexOf('belge den')).toBeLessThan(out.indexOf('bekle'));
2538
+ });
2539
+
2540
+ it('[qu] wait + or-run + from-phrase renders verb-final, from-phrase in-clause', () => {
2541
+ const out = new GrammarTransformer('en', 'qu').transform(
2542
+ 'wait for pointermove(clientY) or pointerup(clientY) from document'
2543
+ );
2544
+ expect(out).toContain('utaq');
2545
+ expect(out.trim().endsWith('suyay')).toBe(true);
2546
+ expect(out.indexOf('qillqa manta')).toBeGreaterThanOrEqual(0);
2547
+ expect(out.indexOf('qillqa manta')).toBeLessThan(out.indexOf('pointermove'));
2548
+ });
2549
+
2550
+ it('[tr] repeat-until-event keeps the from-phrase before the verb', () => {
2551
+ const out = new GrammarTransformer('en', 'tr').transform(
2552
+ 'repeat until event pointerup from document'
2553
+ );
2554
+ expect(out.trim().endsWith('tekrarla')).toBe(true);
2555
+ expect(out.indexOf('belge den')).toBeGreaterThanOrEqual(0);
2556
+ expect(out.indexOf('belge den')).toBeLessThan(out.indexOf('tekrarla'));
2557
+ });
2558
+
2559
+ it('[bn] trailing-end fragment renders the terminator after the verb', () => {
2560
+ // `then`-split fragment of `… then wait 200ms end` (repeat-while body).
2561
+ const out = new GrammarTransformer('en', 'bn').transform('wait 200ms end');
2562
+ expect(out.trim()).toBe('200ms কে অপেক্ষা শেষ');
2563
+ expect(out).not.toContain('শেষ কে'); // terminator no longer inside the object phrase
2564
+ });
2565
+
2566
+ it('[qu] inline-if set keeps its verb-final tail before the terminator', () => {
2567
+ const out = new GrammarTransformer('en', 'qu').transform(
2568
+ 'if newWidth < minWidth then set newWidth to minWidth end'
2569
+ );
2570
+ expect(out).toMatch(/man churanay tukuy\s*$/);
2571
+ });
2572
+
2573
+ it('[tr] trailing-end set fragment renders verb-final with trailing terminator', () => {
2574
+ // Previously the set-to rule's end-guard predicate skipped verb-final
2575
+ // reorder for these fragments; with the terminator stripped first, the
2576
+ // rule applies and the terminator follows the verb.
2577
+ const out = new GrammarTransformer('en', 'tr').transform('set dragClass to "sorting" end');
2578
+ expect(out.trim()).toBe('dragClass i "sorting" e ayarla son');
2579
+ });
2580
+
2581
+ const draggableWait =
2582
+ 'wait for pointermove(clientX, clientY) or pointerup(clientX, clientY) from document';
2583
+ it('[tr] draggable-shaped wait (clientX, clientY) is verb-final', () => {
2584
+ const out = new GrammarTransformer('en', 'tr').transform(draggableWait);
2585
+ expect(out.trim().endsWith('bekle')).toBe(true);
2586
+ expect(out.indexOf('belge den')).toBeLessThan(out.indexOf('bekle'));
2587
+ });
2588
+ it('[qu] draggable-shaped wait (clientX, clientY) is verb-final', () => {
2589
+ const out = new GrammarTransformer('en', 'qu').transform(draggableWait);
2590
+ expect(out.trim().endsWith('suyay')).toBe(true);
2591
+ expect(out.indexOf('qillqa manta')).toBeLessThan(out.indexOf('pointermove'));
2592
+ });
2593
+ });
2594
+
2595
+ // The form-submit-prevent head fuses TWO juxtaposed commands (`halt the event
2596
+ // call validateForm()`); parseEventHandler sweeps both into one patient blob
2597
+ // and the SOV patient-first reorder fronted it whole — halt's operand stranded
2598
+ // clause-initial, call's operand riding with it (tr bound validateForm() to
2599
+ // halt and the event to call). sovHaltCallFusedRule splits the blob at the
2600
+ // language's call verb and emits both commands verb-final, joined by the
2601
+ // then-connective (R1 deferred-tail Family H,
2602
+ // docs-internal/HANDOFF_r1-deferred-tail.md).
2603
+ describe('SOV fused halt+call heads render both commands verb-final (Family H)', () => {
2604
+ const src =
2605
+ 'on submit halt the event call validateForm() if result is false log "Invalid form" end';
2606
+ const EXPECT: Array<[string, string]> = [
2607
+ ['ja', 'the イベント を 送信 で 停止 それから validateForm() を 呼び出し'],
2608
+ ['ko', 'the 이벤트 를 제출 할 때 정지 그러면 validateForm() 를 호출'],
2609
+ // Batch 3: tr submit event word gönder→gönderme (gönder is the send verb — captured event "send")
2610
+ ['tr', 'the olay i gönderme de durdur ardından validateForm() i çağır'],
2611
+ ['bn', 'the ঘটনা কে জমা এ থামুন তারপর validateForm() কে কল'],
2612
+ ['hi', 'the घटना को जमा पर रोकें फिर validateForm() को कॉल'],
2613
+ // Batch 3: qu submit event word kachay→apaykachay (kachay is the send verb — captured "send" in this slot)
2614
+ ['qu', 'the ruway ta apaykachay pi sayay chayqa validateForm() ta qayay'],
2615
+ ];
2616
+ for (const [lang, head] of EXPECT) {
2617
+ it(`[${lang}] halt keeps its operand adjacent + verb-final; call follows the connective`, () => {
2618
+ const out = new GrammarTransformer('en', lang).transform(src);
2619
+ expect(out.startsWith(head)).toBe(true);
2620
+ });
2621
+ }
2622
+
2623
+ it('[ja] a plain `halt the event` handler keeps its default render (rule stands down)', () => {
2624
+ const out = new GrammarTransformer('en', 'ja').transform('on submit halt the event');
2625
+ expect(out.trim()).toBe('the イベント を 送信 で 停止');
2626
+ });
2627
+
2628
+ it('[tr] a call-only handler keeps its default render (no halt — rule stands down)', () => {
2629
+ const out = new GrammarTransformer('en', 'tr').transform('on click call myFunction()');
2630
+ expect(out.trim()).toBe('myFunction() i tıklama de çağır');
2631
+ });
2632
+ });
2633
+
2634
+ // SOV renders emit nothing between an if-condition's tail and a POSITIONAL-
2635
+ // headed branch operand (`… .modal 最初 <button/> …`), and the semantic fold's
2636
+ // command-start detection can't see that shape — the condition swallowed the
2637
+ // focus operand's head (focus-trap, ja/ko/qu). transformBlockBody now emits
2638
+ // the target's then-connective at the clause/body seam, gated to exactly the
2639
+ // blind shape (R1 deferred-tail Family G).
2640
+ describe('SOV if-blocks get a then-connective before a positional branch head (Family G)', () => {
2641
+ const src =
2642
+ 'on keydown[key=="Tab"] from .modal if target matches last <button/> in .modal focus first <button/> in .modal halt end';
2643
+ const SEAM: Array<[string, string]> = [
2644
+ ['ja', '.modal それから 最初'],
2645
+ ['ko', '.modal 그러면 첫번째'],
2646
+ ['qu', '.modal chayqa ñawpaq'],
2647
+ ['tr', '.modal ardından ilk'],
2648
+ ['bn', '.modal তারপর প্রথম'],
2649
+ ['hi', '.modal फिर पहला'],
2650
+ ];
2651
+ for (const [lang, seam] of SEAM) {
2652
+ it(`[${lang}] focus-trap renders the connective at the condition/branch seam`, () => {
2653
+ const out = new GrammarTransformer('en', lang).transform(src);
2654
+ expect(out).toContain(seam);
2655
+ });
2656
+ }
2657
+
2658
+ it('[qu] a non-positional branch head gains no connective (form-submit if unchanged)', () => {
2659
+ const out = new GrammarTransformer('en', 'qu').transform(
2660
+ 'on submit halt the event call validateForm() if result is false log "Invalid form" end'
2661
+ );
2662
+ expect(out).toContain('llulla "Invalid form" ta qillqakuy');
2663
+ });
2664
+ });
@@ -6,7 +6,7 @@
6
6
  * language-specific markers and rules.
7
7
  */
8
8
 
9
- import type { LanguageProfile, SemanticRole } from '../types';
9
+ import type { GrammarRule, LanguageProfile, SemanticRole } from '../types';
10
10
  import { reorderRoles, insertMarkers } from '../types';
11
11
 
12
12
  // =============================================================================
@@ -47,6 +47,167 @@ export const englishProfile: LanguageProfile = {
47
47
  ],
48
48
  };
49
49
 
50
+ // =============================================================================
51
+ // Shared SOV rule factory — fused `halt the event call fn()` heads (Family H,
52
+ // docs-internal/HANDOFF_r1-deferred-tail.md)
53
+ // =============================================================================
54
+
55
+ /**
56
+ * The form-submit-prevent head fuses TWO juxtaposed commands (`on submit halt
57
+ * the event call validateForm()`): parseEventHandler reads `halt` as the
58
+ * action and sweeps `the event call validateForm()` into ONE patient blob, so
59
+ * the SOV patient-first reorder fronts the whole blob — halt's operand lands
60
+ * clause-initial far from its verb, and call's operand rides inside the blob
61
+ * (tr bound validateForm() to halt and the event to call). No roleOrder can
62
+ * fix a single-blob patient; this custom render splits the blob at the
63
+ * language's call verb and emits both commands verb-final, joined by the
64
+ * then-connective (the boundary the semantic compound-splitter already
65
+ * respects — the event-debounce geometry):
66
+ *
67
+ * `<halt-operand> <pm> <event> <em> <halt-verb> <conn> <call-args> <pm> <call-verb>`
68
+ *
69
+ * The trailing EVENTBLOCKPLACEHOLDER (tryTransformEventWithBlockBody's mask
70
+ * for an `if … end` tail) is re-emitted clause-final so the caller's
71
+ * strip-and-append keeps working.
72
+ */
73
+ function sovHaltCallFusedRule(cfg: {
74
+ callVerb: string; // the dictionary's call rendering — the blob split point
75
+ patientMarker: string;
76
+ eventMarker: string;
77
+ connective: string;
78
+ }): GrammarRule {
79
+ return {
80
+ name: 'halt-call-fused-verb-final',
81
+ description: 'Split the swept `halt <operand> call <args>` blob; both verbs clause-final',
82
+ priority: 95,
83
+ match: {
84
+ commands: ['halt'],
85
+ requiredRoles: ['action', 'event', 'patient'],
86
+ // Only the juxtaposed-call blob: a plain `halt`/`halt the event` handler
87
+ // keeps its default render. The translated call verb must be present to
88
+ // split on — if the dictionary emission drifts, the rule stands down
89
+ // rather than emitting a mangled clause.
90
+ predicate: parsed => {
91
+ const p = parsed.roles.get('patient');
92
+ return (
93
+ !!p && /\bcall\b/.test(p.value) && !!p.translated && p.translated.includes(cfg.callVerb)
94
+ );
95
+ },
96
+ },
97
+ transform: {
98
+ roleOrder: [], // unused — custom takes over
99
+ custom: parsed => {
100
+ const p = parsed.roles.get('patient')!;
101
+ const ev = parsed.roles.get('event')!;
102
+ const act = parsed.roles.get('action')!;
103
+ let blob = (p.translated ?? p.value).trim();
104
+ const PH = 'EVENTBLOCKPLACEHOLDER';
105
+ let tail = '';
106
+ if (blob.endsWith(PH)) {
107
+ blob = blob.slice(0, -PH.length).trim();
108
+ tail = ` ${PH}`;
109
+ }
110
+ const at = blob.indexOf(cfg.callVerb);
111
+ const haltOperand = blob.slice(0, at).trim();
112
+ const callArgs = blob.slice(at + cfg.callVerb.length).trim();
113
+ return (
114
+ [
115
+ haltOperand,
116
+ cfg.patientMarker,
117
+ ev.translated ?? ev.value,
118
+ cfg.eventMarker,
119
+ act.translated ?? act.value,
120
+ cfg.connective,
121
+ callArgs,
122
+ cfg.patientMarker,
123
+ cfg.callVerb,
124
+ ].join(' ') + tail
125
+ );
126
+ },
127
+ },
128
+ };
129
+ }
130
+
131
+ // =============================================================================
132
+ // Shared SOV rule factory — pick range rows (`pick characters 0 to 5 of #note`,
133
+ // docs-internal/HANDOFF_pick-text-range-arc3.md)
134
+ // =============================================================================
135
+
136
+ /**
137
+ * The canonical pick range row decomposes in parseStatement/parseEventHandler as
138
+ * patient=`characters 0` + destination=`5 of #note` (the range's `to` reads as a
139
+ * destination marker), so the default SOV reorder scatters the range across the
140
+ * clause — ja emitted `文字 0 を クリック で 選択 5 の #note に` with the `5`
141
+ * stranded after the verb, unparseable in every SOV language. No roleOrder can
142
+ * reunite a range split across two roles; this custom render reassembles the
143
+ * canonical surface and emits the SOV convention shape (big patient phrase
144
+ * first, verb final):
145
+ *
146
+ * `<root> <gen> <unit> <a> <sep> <b> <pm> [<event> <em>] <verb>`
147
+ *
148
+ * e.g. ja `#note の 文字 0 から 5 を クリック で 選択`. The paired semantic
149
+ * pick pattern (patterns/pick.ts VERB_FINAL_SPECS) parses exactly this shape
150
+ * back, with the range fold normalizing `<a> <sep> <b>` to English `a to b` —
151
+ * the two sides co-evolve per the i18n-renders-semantic-patterns-coevolve
152
+ * lesson; change one and you must re-probe the other.
153
+ *
154
+ * Gated tight to the numeric-endpoint corpus shape (`<unit> <int>` patient +
155
+ * `<int> of <root>` destination); word endpoints (`start`/`end`), modes
156
+ * (`inclusive`) and anything else fall through to the default render unchanged.
157
+ */
158
+ function sovPickRangeRule(cfg: {
159
+ /** Genitive joiner between the root and the unit word (ja `の`, hi `का`). */
160
+ genitive: string;
161
+ /** Range separator between the two endpoints (ja `から`, hi `से`). */
162
+ rangeSep: string;
163
+ /** Patient/object marker after the range (ja `を`, hi `को`). */
164
+ patientMarker: string;
165
+ /** Event marker after the event word (ja `で`, hi `पर`). */
166
+ eventMarker: string;
167
+ }): GrammarRule {
168
+ return {
169
+ name: 'pick-range-verb-final',
170
+ description: 'Reassemble the pick range (unit + endpoints + root) adjacent, verb-final',
171
+ priority: 95,
172
+ match: {
173
+ commands: ['pick'],
174
+ requiredRoles: ['action', 'patient', 'destination'],
175
+ predicate: parsed => {
176
+ const p = parsed.roles.get('patient');
177
+ const d = parsed.roles.get('destination');
178
+ return (
179
+ !!p &&
180
+ !!d &&
181
+ /^\S+\s+\d+$/.test(p.value.trim()) &&
182
+ /^\d+\s+of\s+\S+$/.test(d.value.trim())
183
+ );
184
+ },
185
+ },
186
+ transform: {
187
+ roleOrder: [], // unused — custom takes over
188
+ custom: parsed => {
189
+ const p = parsed.roles.get('patient')!;
190
+ const d = parsed.roles.get('destination')!;
191
+ const act = parsed.roles.get('action')!;
192
+ const ev = parsed.roles.get('event');
193
+ // Unit word from the TRANSLATED patient (`文字 0` → `文字`); endpoints
194
+ // and root from the UNTRANSLATED values — integers and selectors are
195
+ // language-invariant by construction (the R3 rule).
196
+ const pParts = (p.translated ?? p.value).trim().split(/\s+/);
197
+ const a = pParts[pParts.length - 1]!;
198
+ const unit = pParts.slice(0, -1).join(' ');
199
+ const dMatch = d.value.trim().match(/^(\d+)\s+of\s+(\S+)$/)!;
200
+ const b = dMatch[1]!;
201
+ const root = dMatch[2]!;
202
+ const verb = act.translated ?? act.value;
203
+ const head = [root, cfg.genitive, unit, a, cfg.rangeSep, b, cfg.patientMarker];
204
+ const eventPart = ev ? [ev.translated ?? ev.value, cfg.eventMarker] : [];
205
+ return [...head, ...eventPart, verb].join(' ');
206
+ },
207
+ },
208
+ };
209
+ }
210
+
50
211
  // =============================================================================
51
212
  // Japanese (SOV, Postpositions)
52
213
  // =============================================================================
@@ -140,6 +301,20 @@ export const japaneseProfile: LanguageProfile = {
140
301
  insertMarkers: true,
141
302
  },
142
303
  },
304
+ // the イベント を 送信 で 停止 それから validateForm() を 呼び出し
305
+ sovHaltCallFusedRule({
306
+ callVerb: '呼び出し',
307
+ patientMarker: 'を',
308
+ eventMarker: 'で',
309
+ connective: 'それから',
310
+ }),
311
+ // #note の 文字 0 から 5 を クリック で 選択
312
+ sovPickRangeRule({
313
+ genitive: 'の',
314
+ rangeSep: 'から',
315
+ patientMarker: 'を',
316
+ eventMarker: 'で',
317
+ }),
143
318
  ],
144
319
  };
145
320
 
@@ -248,6 +423,20 @@ export const koreanProfile: LanguageProfile = {
248
423
  insertMarkers: true,
249
424
  },
250
425
  },
426
+ // the 이벤트 를 제출 할 때 정지 그러면 validateForm() 를 호출
427
+ sovHaltCallFusedRule({
428
+ callVerb: '호출',
429
+ patientMarker: '를',
430
+ eventMarker: '할 때',
431
+ connective: '그러면',
432
+ }),
433
+ // #note 의 문자 0 부터 5 를 클릭 할 때 선택
434
+ sovPickRangeRule({
435
+ genitive: '의',
436
+ rangeSep: '부터',
437
+ patientMarker: '를',
438
+ eventMarker: '할 때',
439
+ }),
251
440
  ],
252
441
  };
253
442
 
@@ -420,6 +609,12 @@ export const turkishProfile: LanguageProfile = {
420
609
  morphology: 'agglutinative',
421
610
  direction: 'ltr',
422
611
 
612
+ // NOTE: roles absent from this order are safety-net-appended AFTER the verb
613
+ // by reorderRoles. That stranding is what the semantic parser's generated
614
+ // patterns + reclaim tolerances were built around, so extending this order
615
+ // wholesale re-renders (and breaks) the whole tr corpus — fix individual
616
+ // defective shapes with per-command `rules` below instead (see the
617
+ // wait/repeat-until rules from the transformer-rendering arc).
423
618
  canonicalOrder: ['patient', 'event', 'action'],
424
619
 
425
620
  markers: [
@@ -516,6 +711,58 @@ export const turkishProfile: LanguageProfile = {
516
711
  insertMarkers: true,
517
712
  },
518
713
  },
714
+ {
715
+ name: 'wait-oblique-verb-final',
716
+ description: "Keep wait's from-phrase and or-run inside the clause, verb-final",
717
+ priority: 90,
718
+ match: {
719
+ commands: ['wait', 'bekle'],
720
+ // Only the multi-argument form (`wait for X or Y from Z`) strands:
721
+ // without this rule the safety-net appends duration+source AFTER the
722
+ // verb (`bekle … belge den`), and the stranded from-phrase is captured
723
+ // by the NEXT statement as a junk trigger event (behavior-sortable R3).
724
+ requiredRoles: ['action', 'duration', 'source'],
725
+ // Never reorder a fused single-line handler — its event must stay put.
726
+ predicate: parsed => !parsed.roles.has('event'),
727
+ },
728
+ transform: {
729
+ // belge den pointermove(clientY) veya pointerup(clientY) bekle
730
+ roleOrder: ['patient', 'source', 'duration', 'action'],
731
+ insertMarkers: true,
732
+ },
733
+ },
734
+ {
735
+ name: 'repeat-until-event-verb-final',
736
+ description: "Keep repeat-until-event's from-phrase before the verb",
737
+ priority: 90,
738
+ match: {
739
+ commands: ['repeat', 'tekrarla'],
740
+ requiredRoles: ['action', 'patient', 'source'],
741
+ // Gate to the until-event head; `repeat for item in .items` also
742
+ // carries patient+source and must keep its (parseable) default order.
743
+ predicate: parsed => /^repeat\s+until\s+event\b/i.test((parsed.original ?? '').trim()),
744
+ },
745
+ transform: {
746
+ // kadar olay pointerup i belge den tekrarla — matched semantic-side by
747
+ // repeat-tr-until-head-verb-final.
748
+ roleOrder: ['patient', 'source', 'action'],
749
+ insertMarkers: true,
750
+ },
751
+ },
752
+ // the olay i gönder de durdur ardından validateForm() i çağır
753
+ sovHaltCallFusedRule({
754
+ callVerb: 'çağır',
755
+ patientMarker: 'i',
756
+ eventMarker: 'de',
757
+ connective: 'ardından',
758
+ }),
759
+ // #note nin karakterler 0 ile 5 i tıklama de seç
760
+ sovPickRangeRule({
761
+ genitive: 'nin',
762
+ rangeSep: 'ile',
763
+ patientMarker: 'i',
764
+ eventMarker: 'de',
765
+ }),
519
766
  ],
520
767
  };
521
768
 
@@ -692,6 +939,8 @@ export const quechuaProfile: LanguageProfile = {
692
939
  morphology: 'agglutinative', // Actually polysynthetic
693
940
  direction: 'ltr',
694
941
 
942
+ // NOTE: roles absent from this order are safety-net-appended AFTER the verb
943
+ // (see the Turkish profile note) — extend via per-command `rules`, not here.
695
944
  canonicalOrder: ['patient', 'source', 'destination', 'event', 'action'],
696
945
 
697
946
  markers: [
@@ -705,6 +954,42 @@ export const quechuaProfile: LanguageProfile = {
705
954
  { form: 'wan', role: 'style', position: 'postposition', required: false },
706
955
  { form: 'hina', role: 'method', position: 'postposition', required: false }, // "as/like"
707
956
  ],
957
+
958
+ rules: [
959
+ {
960
+ name: 'wait-oblique-verb-final',
961
+ description: "Keep wait's from-phrase and or-run inside the clause, verb-final",
962
+ priority: 90,
963
+ match: {
964
+ commands: ['wait', 'suyay'],
965
+ // Only `wait for X or Y from Z` strands: duration is missing from
966
+ // canonicalOrder, so the or-run lands AFTER the verb and the next
967
+ // trigger glues it into its event (behavior-sortable R3). See the
968
+ // matching Turkish rule.
969
+ requiredRoles: ['action', 'duration', 'source'],
970
+ predicate: parsed => !parsed.roles.has('event'),
971
+ },
972
+ transform: {
973
+ // qillqa manta pointermove(clientY) utaq pointerup(clientY) suyay
974
+ roleOrder: ['patient', 'source', 'duration', 'action'],
975
+ insertMarkers: true,
976
+ },
977
+ },
978
+ // the ruway ta kachay pi sayay chayqa validateForm() ta qayay
979
+ sovHaltCallFusedRule({
980
+ callVerb: 'qayay',
981
+ patientMarker: 'ta',
982
+ eventMarker: 'pi',
983
+ connective: 'chayqa',
984
+ }),
985
+ // #note pa sanampa 0 kama 5 ta ñitiy pi akllay
986
+ sovPickRangeRule({
987
+ genitive: 'pa',
988
+ rangeSep: 'kama',
989
+ patientMarker: 'ta',
990
+ eventMarker: 'pi',
991
+ }),
992
+ ],
708
993
  };
709
994
 
710
995
  // =============================================================================
@@ -749,6 +1034,8 @@ export const bengaliProfile: LanguageProfile = {
749
1034
 
750
1035
  // Bengali: Object comes before verb, postpositions follow nouns
751
1036
  // "on click increment #count" → "#count কে ক্লিক এ বৃদ্ধি"
1037
+ // NOTE: roles absent from this order are safety-net-appended AFTER the verb
1038
+ // (see the Turkish profile note) — extend via per-command `rules`, not here.
752
1039
  canonicalOrder: ['patient', 'event', 'action'],
753
1040
 
754
1041
  markers: [
@@ -827,6 +1114,20 @@ export const bengaliProfile: LanguageProfile = {
827
1114
  insertMarkers: true,
828
1115
  },
829
1116
  },
1117
+ // the ঘটনা কে জমা এ থামুন তারপর validateForm() কে কল
1118
+ sovHaltCallFusedRule({
1119
+ callVerb: 'কল',
1120
+ patientMarker: 'কে',
1121
+ eventMarker: 'এ',
1122
+ connective: 'তারপর',
1123
+ }),
1124
+ // #note র অক্ষর 0 থেকে 5 কে ক্লিক এ বাছুন
1125
+ sovPickRangeRule({
1126
+ genitive: 'র',
1127
+ rangeSep: 'থেকে',
1128
+ patientMarker: 'কে',
1129
+ eventMarker: 'এ',
1130
+ }),
830
1131
  ],
831
1132
  };
832
1133
 
@@ -1040,6 +1341,20 @@ export const hindiProfile: LanguageProfile = {
1040
1341
  insertMarkers: true,
1041
1342
  },
1042
1343
  },
1344
+ // the घटना को जमा पर रोकें फिर validateForm() को कॉल
1345
+ sovHaltCallFusedRule({
1346
+ callVerb: 'कॉल',
1347
+ patientMarker: 'को',
1348
+ eventMarker: 'पर',
1349
+ connective: 'फिर',
1350
+ }),
1351
+ // #note का अक्षर 0 से 5 को क्लिक पर चुनें
1352
+ sovPickRangeRule({
1353
+ genitive: 'का',
1354
+ rangeSep: 'से',
1355
+ patientMarker: 'को',
1356
+ eventMarker: 'पर',
1357
+ }),
1043
1358
  ],
1044
1359
  };
1045
1360
 
@@ -1901,6 +1901,18 @@ export class GrammarTransformer {
1901
1901
  return this.transformBlock(block);
1902
1902
  }
1903
1903
 
1904
+ // 0a. Fragment carrying a stranded trailing block terminator (`wait 200ms
1905
+ // end`, `set x to y end` — what the `then`-splitter leaves from
1906
+ // `if … then <cmd> end`). `end` is not a marker, so left in place it is
1907
+ // swept into the open role's VALUE and rendered inside that phrase
1908
+ // (bn `200ms শেষ কে অপেক্ষা`). Strip it, transform the clause alone, and
1909
+ // re-append the translated terminator after the verb — the same tail
1910
+ // position transformBlockBody emits for event-headed blocks.
1911
+ const strippedEnd = this.transformWithTrailingEnd(input);
1912
+ if (strippedEnd !== null) {
1913
+ return strippedEnd;
1914
+ }
1915
+
1904
1916
  // 0b. `set @attr to V on <scope>` (S1 tabs-aria): strip the trailing
1905
1917
  // `on <scope>`, transform the scope-less set normally, then re-insert
1906
1918
  // `on <scope>` where the semantic set patterns expect it.
@@ -1951,6 +1963,36 @@ export class GrammarTransformer {
1951
1963
  return joinTokens(reordered.map(e => e.translated || e.value));
1952
1964
  }
1953
1965
 
1966
+ /**
1967
+ * `<command …> end` fragments: transform the command without its stranded
1968
+ * terminator, then re-append the translated terminator as a standalone
1969
+ * trailing token. Fragments that open a block of their own (`if … end`,
1970
+ * `repeat … end`, `js … end`) bail — their terminator belongs to them and
1971
+ * their dedicated paths handle it.
1972
+ */
1973
+ private transformWithTrailingEnd(input: string): string | null {
1974
+ const src = this.sourceProfile.code;
1975
+ const tokens = input.trim().split(/\s+/);
1976
+ if (tokens.length < 2) {
1977
+ return null;
1978
+ }
1979
+ const sourceEnd = translateWord('end', 'en', src).toLowerCase();
1980
+ if (tokens[tokens.length - 1].toLowerCase() !== sourceEnd) {
1981
+ return null;
1982
+ }
1983
+ const openers = new Set(
1984
+ ['if', 'repeat', 'unless', 'while', 'when', 'live', 'js'].map(k =>
1985
+ translateWord(k, 'en', src).toLowerCase()
1986
+ )
1987
+ );
1988
+ if (tokens.slice(0, -1).some(t => openers.has(t.toLowerCase()))) {
1989
+ return null;
1990
+ }
1991
+ const inner = this.transformSingle(tokens.slice(0, -1).join(' '));
1992
+ const endT = translateWord(tokens[tokens.length - 1], src, this.targetProfile.code);
1993
+ return `${inner} ${endT}`;
1994
+ }
1995
+
1954
1996
  /**
1955
1997
  * Detect and transform an inline JS block (`[on <event>] js <raw js> end`).
1956
1998
  *
@@ -2327,7 +2369,28 @@ export class GrammarTransformer {
2327
2369
  const clauseT = clause ? translateMultiWordValue(clause, src, dst) : '';
2328
2370
  const bodyT = this.transformConditionalBody(bodyTokens);
2329
2371
 
2330
- return [headT, clauseT, bodyT, tailT].filter(s => s.length > 0).join(' ');
2372
+ // SOV condition/branch boundary (R1 deferred-tail Family G): the SOV body
2373
+ // renders its first command operand-first (`最初 <button/> の中 .modal を
2374
+ // フォーカス`), so nothing marks where the condition ends and the branch
2375
+ // operand begins — the semantic fold's command-start detection needs a
2376
+ // `{value}{particle}` run followed by a verb, which a POSITIONAL-headed
2377
+ // operand (`first <button/> …`) never forms, and the condition scan
2378
+ // swallows the operand's head (focus-trap: ja focus.patient fell to the
2379
+ // `me` default, ko/qu to the `.modal` tail). Emit the target's
2380
+ // then-connective at the seam — the boundary the fold already respects
2381
+ // (isThenKeyword) — gated to exactly the blind shape: SOV target, a
2382
+ // positional keyword right after the branch's command verb, and no `then`
2383
+ // already ending the condition.
2384
+ const POSITIONAL_BRANCH_HEADS = new Set(['first', 'last', 'next', 'previous', 'closest']);
2385
+ const thenT =
2386
+ this.targetProfile.wordOrder === 'SOV' &&
2387
+ clauseT &&
2388
+ POSITIONAL_BRANCH_HEADS.has(bodyTokens[1]?.toLowerCase()) &&
2389
+ inner[bodyStart - 1]?.toLowerCase() !== 'then'
2390
+ ? translateWord('then', src, dst)
2391
+ : '';
2392
+
2393
+ return [headT, clauseT, thenT, bodyT, tailT].filter(s => s.length > 0).join(' ');
2331
2394
  }
2332
2395
 
2333
2396
  /**
@@ -47,7 +47,9 @@ describe('New Language Support', () => {
47
47
 
48
48
  it('should handle events properly', () => {
49
49
  const result = translator.translate('on mouseenter add .hover', { from: 'en', to: 'tr' });
50
- expect(result).toContain('fare_gir');
50
+ // V3c burn-down 2026-07-14: fare_gir never round-tripped on the parse
51
+ // side (broken listener); mouseenter is English passthrough now.
52
+ expect(result).toContain('mouseenter');
51
53
  expect(result).toContain('ekle');
52
54
  });
53
55
 
@@ -198,8 +200,12 @@ describe('New Language Support', () => {
198
200
 
199
201
  it('should handle double-click events', () => {
200
202
  const result = translator.translate('on dblclick focus on me', { from: 'en', to: 'sw' });
201
- expect(result).toContain('bonyeza_mara_mbili'); // dblclick → bonyeza_mara_mbili
202
- expect(result).toContain('zingatia'); // focus → zingatia
203
+ // V3c burn-down 2026-07-14: bonyeza_mara_mbili never round-tripped on
204
+ // the parse side (broken listener); dblclick is English passthrough now.
205
+ expect(result).toContain('dblclick');
206
+ // V3 Batch 2: events.focus realigned zingatia → lenga (zingatia never
207
+ // tokenized as an event — matches commands.focus and the S5b table)
208
+ expect(result).toContain('lenga'); // focus → lenga
203
209
  });
204
210
 
205
211
  it('should translate navigation commands', () => {