@lokascript/framework 4.6.0 → 4.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 (116) hide show
  1. package/CHANGELOG.md +121 -1
  2. package/dist/api/create-dsl.d.ts +18 -2
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/index.d.ts +1 -0
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +1799 -713
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/api/translation-loss.d.ts +84 -0
  9. package/dist/api/translation-loss.d.ts.map +1 -0
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +1057 -684
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/matching/confidence-model.d.ts +54 -0
  15. package/dist/core/matching/confidence-model.d.ts.map +1 -0
  16. package/dist/core/matching/index.d.ts +12 -0
  17. package/dist/core/matching/index.d.ts.map +1 -0
  18. package/dist/core/matching/match-skeleton.d.ts +114 -0
  19. package/dist/core/matching/match-skeleton.d.ts.map +1 -0
  20. package/dist/core/matching/stock-confidence.d.ts +15 -0
  21. package/dist/core/matching/stock-confidence.d.ts.map +1 -0
  22. package/dist/core/matching/stock-reader.d.ts +63 -0
  23. package/dist/core/matching/stock-reader.d.ts.map +1 -0
  24. package/dist/core/matching/stock-reading.d.ts +44 -0
  25. package/dist/core/matching/stock-reading.d.ts.map +1 -0
  26. package/dist/core/matching/value-reader.d.ts +62 -0
  27. package/dist/core/matching/value-reader.d.ts.map +1 -0
  28. package/dist/core/pattern-matching/index.js +561 -660
  29. package/dist/core/pattern-matching/index.js.map +1 -1
  30. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
  31. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  32. package/dist/core/types.d.ts +53 -0
  33. package/dist/core/types.d.ts.map +1 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/generation/index.d.ts +2 -0
  36. package/dist/generation/index.d.ts.map +1 -1
  37. package/dist/generation/index.js +593 -1
  38. package/dist/generation/index.js.map +1 -1
  39. package/dist/generation/pattern-render.d.ts +72 -0
  40. package/dist/generation/pattern-render.d.ts.map +1 -0
  41. package/dist/generation/schema-patterns.d.ts +152 -0
  42. package/dist/generation/schema-patterns.d.ts.map +1 -0
  43. package/dist/index.cjs +2016 -904
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.js +1998 -903
  46. package/dist/index.js.map +1 -1
  47. package/dist/ir/index.js.map +1 -1
  48. package/dist/parsing/index.js +561 -660
  49. package/dist/parsing/index.js.map +1 -1
  50. package/dist/testing/gate/index.d.ts +8 -0
  51. package/dist/testing/gate/index.d.ts.map +1 -0
  52. package/dist/testing/gate/index.js +664 -0
  53. package/dist/testing/gate/index.js.map +1 -0
  54. package/dist/testing/gate/lint/format.d.ts +7 -0
  55. package/dist/testing/gate/lint/format.d.ts.map +1 -0
  56. package/dist/testing/gate/lint/index.d.ts +30 -0
  57. package/dist/testing/gate/lint/index.d.ts.map +1 -0
  58. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
  59. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
  60. package/dist/testing/gate/lint/rules/index.d.ts +14 -0
  61. package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
  62. package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
  63. package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
  64. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
  65. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
  66. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
  67. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
  68. package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
  69. package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
  70. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
  71. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
  72. package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
  73. package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
  74. package/dist/testing/gate/lint/types.d.ts +74 -0
  75. package/dist/testing/gate/lint/types.d.ts.map +1 -0
  76. package/dist/testing/gate/round-trip.d.ts +184 -0
  77. package/dist/testing/gate/round-trip.d.ts.map +1 -0
  78. package/package.json +6 -2
  79. package/src/__test__/fixtures/sql-dsl.ts +8 -2
  80. package/src/api/create-dsl-engine.test.ts +150 -0
  81. package/src/api/create-dsl.test.ts +7 -0
  82. package/src/api/create-dsl.ts +173 -22
  83. package/src/api/index.ts +1 -0
  84. package/src/api/translation-loss.test.ts +89 -0
  85. package/src/api/translation-loss.ts +128 -0
  86. package/src/core/index.ts +1 -0
  87. package/src/core/matching/confidence-model.ts +58 -0
  88. package/src/core/matching/index.ts +12 -0
  89. package/src/core/matching/match-skeleton.test.ts +179 -0
  90. package/src/core/matching/match-skeleton.ts +621 -0
  91. package/src/core/matching/stock-confidence.ts +238 -0
  92. package/src/core/matching/stock-reader.test.ts +265 -0
  93. package/src/core/matching/stock-reader.ts +205 -0
  94. package/src/core/matching/stock-reading.ts +681 -0
  95. package/src/core/matching/value-reader.ts +85 -0
  96. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  97. package/src/core/types.ts +53 -0
  98. package/src/generation/index.ts +2 -0
  99. package/src/generation/pattern-render.ts +378 -0
  100. package/src/generation/schema-patterns.test.ts +139 -0
  101. package/src/generation/schema-patterns.ts +974 -0
  102. package/src/testing/gate/index.ts +8 -0
  103. package/src/testing/gate/lint/format.ts +23 -0
  104. package/src/testing/gate/lint/index.ts +73 -0
  105. package/src/testing/gate/lint/lint.test.ts +131 -0
  106. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  107. package/src/testing/gate/lint/rules/index.ts +37 -0
  108. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  109. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  110. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  111. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  112. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  113. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  114. package/src/testing/gate/lint/types.ts +83 -0
  115. package/src/testing/gate/round-trip.test.ts +208 -0
  116. package/src/testing/gate/round-trip.ts +503 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The stock confidence model: how framework's old matcher scored a domain
3
+ * pattern's match (`core/pattern-matching/`), moved here as a `ConfidenceModel`
4
+ * for the matcher's skeleton (domains rethink Phase 4.7); the old matcher calls
5
+ * it too until Phase 6.
6
+ *
7
+ * Role coverage (a required slot 1, an optional one 0.8, an absent optional one
8
+ * with a default 60% of that), a stem-match penalty (up to 15%, floor 0.5), and
9
+ * two Arabic adjustments: a VSO verb-first boost and the preposition each role
10
+ * takes most naturally.
11
+ */
12
+
13
+ import type { LanguagePattern, SemanticRole, SemanticValue } from '../types';
14
+ import type { ConfidenceBreakdown, ConfidenceContext, ConfidenceModel } from './confidence-model';
15
+
16
+ /**
17
+ * Calculate confidence score for a match (0-1).
18
+ *
19
+ * Confidence is reduced for:
20
+ * - Stem matches (morphological normalization has inherent uncertainty)
21
+ * - Missing optional roles (but less penalty if role has a default value)
22
+ *
23
+ * Confidence is increased for:
24
+ * - VSO languages (Arabic) when pattern starts with a verb
25
+ */
26
+ function scoreParts(ctx: ConfidenceContext): ConfidenceBreakdown {
27
+ const { pattern, captured } = ctx;
28
+ let score = 0;
29
+ let maxScore = 0;
30
+
31
+ // Helper to check if a role has a default value in extraction rules
32
+ const hasDefault = (role: SemanticRole): boolean => {
33
+ return pattern.extraction?.[role]?.default !== undefined;
34
+ };
35
+
36
+ // Score based on captured roles
37
+ for (const token of pattern.template.tokens) {
38
+ if (token.type === 'role') {
39
+ maxScore += 1;
40
+ if (captured.has(token.role)) {
41
+ score += 1;
42
+ }
43
+ } else if (token.type === 'group') {
44
+ // Group tokens are optional - weight depends on whether they have defaults
45
+ for (const subToken of token.tokens) {
46
+ if (subToken.type === 'role') {
47
+ const roleHasDefault = hasDefault(subToken.role);
48
+ const weight = 0.8; // Optional roles: 80% weight
49
+ maxScore += weight;
50
+
51
+ if (captured.has(subToken.role)) {
52
+ // Role was explicitly provided by user
53
+ score += weight;
54
+ } else if (roleHasDefault) {
55
+ // Role has a default - give 60% partial credit since command is semantically complete
56
+ // This prevents penalizing common patterns like "toggle .active" (default: me)
57
+ score += weight * 0.6;
58
+ }
59
+ // If no default and not captured, score += 0 (true penalty for missing info)
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ const roleCoverage = maxScore > 0 ? score / maxScore : 1;
66
+ let baseConfidence = roleCoverage;
67
+ let stemPenalty = 0;
68
+
69
+ // Apply penalty for stem matches
70
+ // Each stem match reduces confidence slightly (e.g., 5% per stem match)
71
+ // This ensures exact matches are preferred over morphological matches
72
+ if (ctx.stemMatchCount > 0 && ctx.totalKeywordMatches > 0) {
73
+ stemPenalty = (ctx.stemMatchCount / ctx.totalKeywordMatches) * 0.15;
74
+ baseConfidence = Math.max(0.5, baseConfidence - stemPenalty);
75
+ }
76
+
77
+ // Apply VSO confidence boost for Arabic verb-first patterns
78
+ const vsoBoost = calculateVSOConfidenceBoost(pattern);
79
+ baseConfidence = Math.min(1.0, baseConfidence + vsoBoost);
80
+
81
+ // Apply preposition disambiguation adjustment for Arabic
82
+ const prepositionAdjustment = arabicPrepositionDisambiguation(pattern, captured);
83
+ baseConfidence = Math.max(0.0, Math.min(1.0, baseConfidence + prepositionAdjustment));
84
+
85
+ return {
86
+ roleCoverage,
87
+ stemPenalty,
88
+ languageBoost: vsoBoost,
89
+ languageAdjustment: prepositionAdjustment,
90
+ final: baseConfidence,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Calculate confidence boost for VSO (Verb-Subject-Object) language patterns.
96
+ * Arabic naturally uses VSO word order, so patterns that start with a verb
97
+ * should receive a confidence boost.
98
+ *
99
+ * Returns +0.15 confidence boost if:
100
+ * - Language is Arabic ('ar')
101
+ * - Pattern's first token is a verb keyword
102
+ *
103
+ * @param pattern The language pattern being matched
104
+ * @returns Confidence boost (0 or 0.15)
105
+ */
106
+ function calculateVSOConfidenceBoost(pattern: LanguagePattern): number {
107
+ // Only apply to Arabic
108
+ if (pattern.language !== 'ar') {
109
+ return 0;
110
+ }
111
+
112
+ // Check if first token in pattern is a literal (keyword)
113
+ const firstToken = pattern.template.tokens[0];
114
+ if (!firstToken || firstToken.type !== 'literal') {
115
+ return 0;
116
+ }
117
+
118
+ // List of Arabic verb keywords (command verbs)
119
+ const ARABIC_VERBS = new Set([
120
+ 'بدل',
121
+ 'غير',
122
+ 'أضف',
123
+ 'أزل',
124
+ 'ضع',
125
+ 'اجعل',
126
+ 'عين',
127
+ 'زد',
128
+ 'انقص',
129
+ 'سجل',
130
+ 'أظهر',
131
+ 'أخف',
132
+ 'شغل',
133
+ 'أرسل',
134
+ 'ركز',
135
+ 'شوش',
136
+ 'توقف',
137
+ 'انسخ',
138
+ 'احذف',
139
+ 'اصنع',
140
+ 'انتظر',
141
+ 'انتقال',
142
+ 'أو',
143
+ ]);
144
+
145
+ // Check if first token value is a verb
146
+ if (ARABIC_VERBS.has(firstToken.value)) {
147
+ return 0.15;
148
+ }
149
+
150
+ // Check alternatives
151
+ if (firstToken.alternatives) {
152
+ for (const alt of firstToken.alternatives) {
153
+ if (ARABIC_VERBS.has(alt)) {
154
+ return 0.15;
155
+ }
156
+ }
157
+ }
158
+
159
+ return 0;
160
+ }
161
+
162
+ /**
163
+ * Arabic preposition disambiguation for confidence adjustment.
164
+ *
165
+ * Different Arabic prepositions are more or less natural for different semantic roles:
166
+ * - على (on/upon) is preferred for patient/target roles (element selectors)
167
+ * - إلى (to) is preferred for destination roles
168
+ * - من (from) is preferred for source roles
169
+ * - في (in) is preferred for location roles
170
+ *
171
+ * This method analyzes the prepositions used with captured semantic roles and
172
+ * adjusts confidence based on idiomaticity:
173
+ * - +0.10 for highly idiomatic preposition choices
174
+ * - -0.10 for less natural preposition choices
175
+ *
176
+ * @param pattern The language pattern being matched
177
+ * @param captured The captured semantic values
178
+ * @returns Confidence adjustment (-0.10 to +0.10)
179
+ */
180
+ function arabicPrepositionDisambiguation(
181
+ pattern: LanguagePattern,
182
+ captured: ReadonlyMap<SemanticRole, SemanticValue>
183
+ ): number {
184
+ // Only apply to Arabic
185
+ if (pattern.language !== 'ar') {
186
+ return 0;
187
+ }
188
+
189
+ let adjustment = 0;
190
+
191
+ // Preferred prepositions for each semantic role
192
+ // Only including roles that commonly use prepositions in Arabic
193
+ const PREFERRED_PREPOSITIONS: Partial<Record<SemanticRole, string[]>> = {
194
+ patient: ['على'], // element selectors prefer على (on/upon)
195
+ destination: ['إلى', 'الى'], // destination prefers إلى (to)
196
+ source: ['من'], // source prefers من (from)
197
+ agent: ['من'], // agent/by prefers من (from/by)
198
+ manner: ['ب'], // manner prefers ب (with/by)
199
+ style: ['ب'], // style prefers ب (with)
200
+ goal: ['إلى', 'الى'], // target state prefers إلى (to)
201
+ method: ['ب'], // method prefers ب (with/by)
202
+ };
203
+
204
+ // Check each captured role for preposition metadata
205
+ for (const [role, value] of captured.entries()) {
206
+ // Skip if no preferred prepositions defined for this role
207
+ const preferred = PREFERRED_PREPOSITIONS[role];
208
+ if (!preferred || preferred.length === 0) {
209
+ continue;
210
+ }
211
+
212
+ // Check if the value has preposition metadata (from Arabic tokenizer)
213
+ // This metadata is attached when a preposition particle token is consumed
214
+ const metadata =
215
+ 'metadata' in value ? (value as { metadata: Record<string, unknown> }).metadata : undefined;
216
+ if (metadata && typeof metadata.prepositionValue === 'string') {
217
+ const usedPreposition = metadata.prepositionValue;
218
+
219
+ // Check if the used preposition is in the preferred list
220
+ if (preferred.includes(usedPreposition)) {
221
+ // Idiomatic choice - boost confidence
222
+ adjustment += 0.1;
223
+ } else {
224
+ // Less natural choice - reduce confidence
225
+ adjustment -= 0.1;
226
+ }
227
+ }
228
+ }
229
+
230
+ // Cap total adjustment at ±0.10 (even if multiple roles analyzed)
231
+ return Math.max(-0.1, Math.min(0.1, adjustment));
232
+ }
233
+
234
+ /** The stock confidence model (stateless: one instance serves every matcher). */
235
+ export const stockConfidenceModel: ConfidenceModel = {
236
+ calculate: ctx => scoreParts(ctx).final,
237
+ calculateWithBreakdown: ctx => scoreParts(ctx),
238
+ };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The stock value reader on the matcher's skeleton (domains rethink Phase 4.7):
3
+ * each kind of value it reads, a greedy slot's stop markers, a slot's word list,
4
+ * the guard against a marker read as a value, and the skip of an optional slot.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import { MatchSkeleton } from './match-skeleton';
8
+ import { createStockReader, greedyStopMarkers, readGreedy } from './stock-reader';
9
+ import { stockConfidenceModel } from './stock-confidence';
10
+ import { TokenStreamImpl } from '../tokenization/token-utils';
11
+ import type { LanguagePattern, LanguageToken, PatternToken, TokenKind } from '../types';
12
+
13
+ function stream(...tokens: Array<[string, TokenKind] | [string, TokenKind, string]>) {
14
+ let offset = 0;
15
+ const out: LanguageToken[] = tokens.map(([value, kind, normalized]) => {
16
+ const token: LanguageToken = {
17
+ value,
18
+ kind,
19
+ position: { start: offset, end: offset + value.length },
20
+ ...(normalized ? { normalized } : {}),
21
+ };
22
+ offset += value.length + 1;
23
+ return token;
24
+ });
25
+ return new TokenStreamImpl(out, 'en');
26
+ }
27
+
28
+ const lit = (value: string, ...alternatives: string[]): PatternToken =>
29
+ alternatives.length ? { type: 'literal', value, alternatives } : { type: 'literal', value };
30
+ const slot = (
31
+ role: string,
32
+ extra: { optional?: boolean; greedy?: boolean; expectedTypes?: string[]; words?: string[] } = {}
33
+ ): PatternToken => ({ type: 'role', role, ...extra }) as PatternToken;
34
+ const group = (...tokens: PatternToken[]): PatternToken => ({
35
+ type: 'group',
36
+ optional: true,
37
+ tokens,
38
+ });
39
+
40
+ function pattern(tokens: PatternToken[], language = 'en'): LanguagePattern {
41
+ return {
42
+ id: 'p',
43
+ language,
44
+ command: 'go',
45
+ priority: 100,
46
+ template: { format: 'p', tokens },
47
+ extraction: {},
48
+ };
49
+ }
50
+
51
+ const matcher = () => new MatchSkeleton(createStockReader(), stockConfidenceModel);
52
+ const read = (p: LanguagePattern, ...tokens: Parameters<typeof stream>) => {
53
+ const result = matcher().matchPattern(stream(...tokens), p);
54
+ return result && Object.fromEntries(result.captured);
55
+ };
56
+
57
+ describe('one value per slot', () => {
58
+ const go = pattern([lit('go'), slot('x')]);
59
+ it.each([
60
+ ['#box', 'selector', { type: 'selector', value: '#box' }],
61
+ ['"hello there"', 'literal', { type: 'literal', value: 'hello there', dataType: 'string' }],
62
+ ['42', 'literal', { type: 'literal', value: 42, dataType: 'number' }],
63
+ ['true', 'literal', { type: 'literal', value: true, dataType: 'boolean' }],
64
+ ['300ms', 'literal', { type: 'literal', value: '300ms', dataType: 'duration' }],
65
+ ['/api/users', 'url', { type: 'literal', value: '/api/users', dataType: 'string' }],
66
+ ['users', 'identifier', { type: 'expression', raw: 'users' }],
67
+ ] as const)('reads %s', (value, kind, want) => {
68
+ expect(read(go, ['go', 'keyword'], [value, kind])).toEqual({ x: want });
69
+ });
70
+
71
+ it('skips an English article before a selector', () => {
72
+ expect(read(go, ['go', 'keyword'], ['the', 'identifier'], ['#box', 'selector'])).toEqual({
73
+ x: { type: 'selector', value: '#box' },
74
+ });
75
+ });
76
+
77
+ it('refuses a value of a type the slot does not take, unless the slot is optional', () => {
78
+ const selectorOnly = pattern([lit('go'), slot('x', { expectedTypes: ['selector'] })]);
79
+ expect(read(selectorOnly, ['go', 'keyword'], ['users', 'identifier'])).toBeNull();
80
+ const optional = pattern([
81
+ lit('go'),
82
+ slot('x', { expectedTypes: ['selector'], optional: true }),
83
+ ]);
84
+ expect(read(optional, ['go', 'keyword'], ['users', 'identifier'])).toEqual({});
85
+ });
86
+ });
87
+
88
+ describe('a greedy slot', () => {
89
+ it('reads to the next marker the pattern owes', () => {
90
+ const select = pattern([
91
+ lit('select'),
92
+ slot('columns', { greedy: true }),
93
+ lit('from'),
94
+ slot('source'),
95
+ ]);
96
+ expect(
97
+ read(
98
+ select,
99
+ ['select', 'keyword'],
100
+ ['name', 'identifier'],
101
+ [',', 'punctuation'],
102
+ ['email', 'identifier'],
103
+ ['from', 'keyword'],
104
+ ['users', 'identifier']
105
+ )
106
+ ).toEqual({
107
+ columns: { type: 'expression', raw: 'name , email' },
108
+ source: { type: 'expression', raw: 'users' },
109
+ });
110
+ });
111
+
112
+ it('reads to the end when nothing is owed, and is refused when it reads nothing', () => {
113
+ const where = pattern([lit('where'), slot('condition', { greedy: true })]);
114
+ expect(
115
+ read(where, ['where', 'keyword'], ['age', 'identifier'], ['>', 'operator'], ['18', 'literal'])
116
+ ).toEqual({ condition: { type: 'expression', raw: 'age > 18' } });
117
+ expect(read(where, ['where', 'keyword'])).toBeNull();
118
+ });
119
+
120
+ it('stops past an optional group at the required literal after it, by value or normalized form', () => {
121
+ const rest: PatternToken[] = [group(lit('where'), slot('c')), lit('選択', 'select')];
122
+ expect([...greedyStopMarkers(rest)]).toEqual(['where', '選択', 'select']);
123
+ const tokens = stream(['users', 'identifier'], ['SELECT', 'keyword', 'select']);
124
+ expect(readGreedy(tokens, greedyStopMarkers(rest))).toEqual({
125
+ type: 'expression',
126
+ raw: 'users',
127
+ });
128
+ expect(tokens.position()).toBe(1);
129
+ });
130
+ });
131
+
132
+ describe('a slot with a word list', () => {
133
+ const click = pattern([lit('when'), slot('action', { words: ['clicks', 'クリック'] })]);
134
+
135
+ it('reads one of its words, in any case, by value or normalized form', () => {
136
+ expect(read(click, ['when', 'keyword'], ['Clicks', 'identifier'])).toEqual({
137
+ action: { type: 'expression', raw: 'Clicks' },
138
+ });
139
+ expect(read(click, ['when', 'keyword'], ['クリックする', 'identifier', 'クリック'])).toEqual({
140
+ action: { type: 'expression', raw: 'クリックする' },
141
+ });
142
+ });
143
+
144
+ it('leaves any other word unread', () => {
145
+ expect(read(click, ['when', 'keyword'], ['zzqx', 'identifier'])).toBeNull();
146
+ });
147
+
148
+ it('gives the word to the required slot when the optional slot before it took it (behaviorspec `when clicks zzqx`)', () => {
149
+ const when = pattern([
150
+ lit('when'),
151
+ group(slot('actor', { optional: true })),
152
+ slot('action', { words: ['clicks'] }),
153
+ ]);
154
+ const result = matcher().matchPattern(
155
+ stream(['when', 'keyword'], ['clicks', 'identifier'], ['zzqx', 'identifier']),
156
+ when
157
+ );
158
+ expect(result && Object.fromEntries(result.captured)).toEqual({
159
+ action: { type: 'expression', raw: 'clicks' },
160
+ });
161
+ // `zzqx` is left for the caller to refuse as unread.
162
+ expect(result?.consumedTokens).toBe(2);
163
+ });
164
+
165
+ it('an optional one is skipped for a word it does not list (voice `close zzqx`)', () => {
166
+ const close = pattern([lit('close'), slot('patient', { optional: true, words: ['tab'] })]);
167
+ expect(read(close, ['close', 'keyword'], ['tab', 'identifier'])).toEqual({
168
+ patient: { type: 'expression', raw: 'tab' },
169
+ });
170
+ const result = matcher().matchPattern(
171
+ stream(['close', 'keyword'], ['zzqx', 'identifier']),
172
+ close
173
+ );
174
+ expect(result && Object.fromEntries(result.captured)).toEqual({});
175
+ expect(result?.consumedTokens).toBe(1);
176
+ });
177
+ });
178
+
179
+ describe('the marker guard and the optional skip', () => {
180
+ it('a required slot does not read the marker the pattern wants next', () => {
181
+ // SOV `{patient} を 実行` given `を 実行`: the patient is missing, not `を`.
182
+ const sov = pattern([slot('patient'), lit('を'), lit('実行')], 'ja');
183
+ expect(read(sov, ['を', 'particle'], ['実行', 'keyword'])).toBeNull();
184
+ });
185
+
186
+ it('an optional slot does not read a marker the rest of the pattern owes, through its optional groups', () => {
187
+ // behaviorspec `when user types into #search`: the typed text is optional and has no marker.
188
+ const when = pattern([
189
+ lit('when'),
190
+ slot('action'),
191
+ group(slot('value', { optional: true })),
192
+ group(lit('on'), slot('target')),
193
+ group(lit('into'), slot('destination')),
194
+ ]);
195
+ expect(
196
+ read(
197
+ when,
198
+ ['when', 'keyword'],
199
+ ['types', 'identifier'],
200
+ ['into', 'keyword'],
201
+ ['#q', 'selector']
202
+ )
203
+ ).toEqual({
204
+ action: { type: 'expression', raw: 'types' },
205
+ destination: { type: 'selector', value: '#q' },
206
+ });
207
+ expect(
208
+ read(
209
+ when,
210
+ ['when', 'keyword'],
211
+ ['types', 'identifier'],
212
+ ["'hi'", 'literal'],
213
+ ['into', 'keyword'],
214
+ ['#q', 'selector']
215
+ )
216
+ ).toEqual({
217
+ action: { type: 'expression', raw: 'types' },
218
+ value: { type: 'literal', value: 'hi', dataType: 'string' },
219
+ destination: { type: 'selector', value: '#q' },
220
+ });
221
+ });
222
+
223
+ it('an optional slot is skipped for the literal the pattern wants next, when the rest then reads to the end', () => {
224
+ const p = pattern([lit('go'), group(slot('x', { optional: true })), lit('now')]);
225
+ expect(read(p, ['go', 'keyword'], ['now', 'keyword'])).toEqual({});
226
+ expect(read(p, ['go', 'keyword'], ['#a', 'selector'], ['now', 'keyword'])).toEqual({
227
+ x: { type: 'selector', value: '#a' },
228
+ });
229
+ });
230
+ });
231
+
232
+ describe('a required slot takes back the optional slot before it', () => {
233
+ it('when the optional slot took its value (behaviorspec `when clicks`)', () => {
234
+ const when = pattern([lit('when'), group(slot('actor', { optional: true })), slot('action')]);
235
+ expect(read(when, ['when', 'keyword'], ['clicks', 'identifier'])).toEqual({
236
+ action: { type: 'expression', raw: 'clicks' },
237
+ });
238
+ expect(
239
+ read(when, ['when', 'keyword'], ['user', 'identifier'], ['clicks', 'identifier'])
240
+ ).toEqual({
241
+ actor: { type: 'expression', raw: 'user' },
242
+ action: { type: 'expression', raw: 'clicks' },
243
+ });
244
+ });
245
+
246
+ it('when it stands at its own marker (flow `orders として 取得変数`)', () => {
247
+ const capture = pattern(
248
+ [
249
+ group(slot('patient', { optional: true })),
250
+ slot('destination'),
251
+ lit('として'),
252
+ lit('取得変数'),
253
+ ],
254
+ 'ja'
255
+ );
256
+ expect(
257
+ read(capture, ['orders', 'identifier'], ['として', 'particle'], ['取得変数', 'keyword'])
258
+ ).toEqual({ destination: { type: 'expression', raw: 'orders' } });
259
+ });
260
+
261
+ it('not when something was read between them', () => {
262
+ const p = pattern([group(slot('a', { optional: true })), lit('to'), slot('b')]);
263
+ expect(read(p, ['x', 'identifier'], ['to', 'keyword'])).toBeNull();
264
+ });
265
+ });