@lokascript/framework 4.6.0 → 4.7.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 (115) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/dist/api/create-dsl.d.ts +13 -0
  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 +1756 -710
  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 +1047 -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 +56 -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 +48 -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 +583 -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 +1979 -907
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.js +1961 -906
  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 +632 -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 +163 -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 +86 -0
  81. package/src/api/create-dsl.ts +141 -18
  82. package/src/api/index.ts +1 -0
  83. package/src/api/translation-loss.test.ts +89 -0
  84. package/src/api/translation-loss.ts +128 -0
  85. package/src/core/index.ts +1 -0
  86. package/src/core/matching/confidence-model.ts +58 -0
  87. package/src/core/matching/index.ts +12 -0
  88. package/src/core/matching/match-skeleton.test.ts +179 -0
  89. package/src/core/matching/match-skeleton.ts +621 -0
  90. package/src/core/matching/stock-confidence.ts +238 -0
  91. package/src/core/matching/stock-reader.test.ts +181 -0
  92. package/src/core/matching/stock-reader.ts +186 -0
  93. package/src/core/matching/stock-reading.ts +681 -0
  94. package/src/core/matching/value-reader.ts +85 -0
  95. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  96. package/src/core/types.ts +48 -0
  97. package/src/generation/index.ts +2 -0
  98. package/src/generation/pattern-render.ts +378 -0
  99. package/src/generation/schema-patterns.test.ts +99 -0
  100. package/src/generation/schema-patterns.ts +962 -0
  101. package/src/testing/gate/index.ts +8 -0
  102. package/src/testing/gate/lint/format.ts +23 -0
  103. package/src/testing/gate/lint/index.ts +73 -0
  104. package/src/testing/gate/lint/lint.test.ts +131 -0
  105. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  106. package/src/testing/gate/lint/rules/index.ts +37 -0
  107. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  108. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  109. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  110. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  111. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  112. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  113. package/src/testing/gate/lint/types.ts +83 -0
  114. package/src/testing/gate/round-trip.test.ts +118 -0
  115. package/src/testing/gate/round-trip.ts +438 -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,181 @@
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, the guard against a
4
+ * 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[] } = {}
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('the marker guard and the optional skip', () => {
133
+ it('a required slot does not read the marker the pattern wants next', () => {
134
+ // SOV `{patient} を 実行` given `を 実行`: the patient is missing, not `を`.
135
+ const sov = pattern([slot('patient'), lit('を'), lit('実行')], 'ja');
136
+ expect(read(sov, ['を', 'particle'], ['実行', 'keyword'])).toBeNull();
137
+ });
138
+
139
+ it('an optional slot is skipped for the literal the pattern wants next, when the rest then reads to the end', () => {
140
+ const p = pattern([lit('go'), group(slot('x', { optional: true })), lit('now')]);
141
+ expect(read(p, ['go', 'keyword'], ['now', 'keyword'])).toEqual({});
142
+ expect(read(p, ['go', 'keyword'], ['#a', 'selector'], ['now', 'keyword'])).toEqual({
143
+ x: { type: 'selector', value: '#a' },
144
+ });
145
+ });
146
+ });
147
+
148
+ describe('a required slot takes back the optional slot before it', () => {
149
+ it('when the optional slot took its value (behaviorspec `when clicks`)', () => {
150
+ const when = pattern([lit('when'), group(slot('actor', { optional: true })), slot('action')]);
151
+ expect(read(when, ['when', 'keyword'], ['clicks', 'identifier'])).toEqual({
152
+ action: { type: 'expression', raw: 'clicks' },
153
+ });
154
+ expect(
155
+ read(when, ['when', 'keyword'], ['user', 'identifier'], ['clicks', 'identifier'])
156
+ ).toEqual({
157
+ actor: { type: 'expression', raw: 'user' },
158
+ action: { type: 'expression', raw: 'clicks' },
159
+ });
160
+ });
161
+
162
+ it('when it stands at its own marker (flow `orders として 取得変数`)', () => {
163
+ const capture = pattern(
164
+ [
165
+ group(slot('patient', { optional: true })),
166
+ slot('destination'),
167
+ lit('として'),
168
+ lit('取得変数'),
169
+ ],
170
+ 'ja'
171
+ );
172
+ expect(
173
+ read(capture, ['orders', 'identifier'], ['として', 'particle'], ['取得変数', 'keyword'])
174
+ ).toEqual({ destination: { type: 'expression', raw: 'orders' } });
175
+ });
176
+
177
+ it('not when something was read between them', () => {
178
+ const p = pattern([group(slot('a', { optional: true })), lit('to'), slot('b')]);
179
+ expect(read(p, ['x', 'identifier'], ['to', 'keyword'])).toBeNull();
180
+ });
181
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The stock value reader: what a domain grammar's matcher reads at each role
3
+ * slot, on the matcher's skeleton (domains rethink Phase 4.7,
4
+ * `docs-internal/DOMAINS_RETHINK.md`). Hyperscript's reader is semantic's
5
+ * `HyperscriptValueReader`; a domain takes this one.
6
+ *
7
+ * - A **greedy** slot (`RoleSpec.greedy`: a SQL condition, a column list) reads
8
+ * every token up to the next marker the pattern still owes, or the end, as
9
+ * one expression (`readGreedy`).
10
+ * - Any other slot reads one value (`readRoleValue`, the old matcher's reading):
11
+ * a selector, a quoted string, a number, a boolean, a duration, a URL, an
12
+ * identifier, or one of the multi-token forms it knows (`user.name`,
13
+ * `#dialog.showModal()`), checked against the slot's expected types.
14
+ * - A **required** slot reads nothing where the stream is at the marker the
15
+ * pattern wants next (the old matcher's guard against an SOV marker taken as
16
+ * a value). Where a required slot finds nothing to read, and the optional,
17
+ * marker-less slot just before it read a value with nothing read since, that
18
+ * value is given back and the required slot reads again (the old matcher's
19
+ * backtrack: behaviorspec `when clicks`, where the optional actor took
20
+ * `clicks`; flow `orders として 取得変数`, where the optional patient took
21
+ * `orders`).
22
+ * - An **optional** slot may be skipped for a token the pattern wants next, the
23
+ * skip kept only when the rest of the pattern then reads to the end.
24
+ */
25
+
26
+ import type {
27
+ StreamMark,
28
+ LanguagePattern,
29
+ LanguageToken,
30
+ PatternToken,
31
+ SemanticRole,
32
+ SemanticValue,
33
+ TokenStream,
34
+ } from '../types';
35
+ import type { MatchView, RoleSlot, ValueReader } from './value-reader';
36
+ import { readRoleValue, type ReadingProfile } from './stock-reading';
37
+
38
+ /**
39
+ * The markers a greedy value stops at, from the pattern tokens still owed after
40
+ * it: the next literal (with its alternatives), and each optional group's
41
+ * leading literal up to and including the first required token.
42
+ */
43
+ export function greedyStopMarkers(rest: readonly PatternToken[]): Set<string> {
44
+ const markers = new Set<string>();
45
+ const add = (pt: PatternToken & { type: 'literal' }): void => {
46
+ markers.add(pt.value.toLowerCase());
47
+ for (const alt of pt.alternatives ?? []) markers.add(alt.toLowerCase());
48
+ };
49
+ for (const pt of rest) {
50
+ if (pt.type === 'literal') {
51
+ add(pt);
52
+ break;
53
+ }
54
+ if (pt.type === 'group') {
55
+ const lead = pt.tokens.find(t => t.type === 'literal');
56
+ if (lead?.type === 'literal') add(lead);
57
+ // For optional groups, continue scanning past them so the greedy capture
58
+ // also sees subsequent required literals (the SOV verb after an optional
59
+ // WHERE group).
60
+ if (!pt.optional) break;
61
+ }
62
+ }
63
+ return markers;
64
+ }
65
+
66
+ /** Whether `token` is one of the stop markers, by its value or its normalized form. */
67
+ function isStopMarker(token: LanguageToken, stopMarkers: ReadonlySet<string>): boolean {
68
+ if (stopMarkers.size === 0) return false;
69
+ if (stopMarkers.has(token.value.toLowerCase())) return true;
70
+ return !!token.normalized && stopMarkers.has(token.normalized.toLowerCase());
71
+ }
72
+
73
+ /**
74
+ * Read every token up to a stop marker (or the end) as one expression value,
75
+ * advancing past them; null (consuming nothing) when the first token stops it.
76
+ */
77
+ export function readGreedy(
78
+ tokens: TokenStream,
79
+ stopMarkers: ReadonlySet<string>
80
+ ): SemanticValue | null {
81
+ const values: string[] = [];
82
+ while (!tokens.isAtEnd()) {
83
+ const next = tokens.peek();
84
+ if (!next || isStopMarker(next, stopMarkers)) break;
85
+ values.push(next.value);
86
+ tokens.advance();
87
+ }
88
+ return values.length > 0 ? { type: 'expression', raw: values.join(' ') } : null;
89
+ }
90
+
91
+ /** The stock reader, for a matcher whose profiles it may consult (possessive words). */
92
+ export class StockValueReader implements ValueReader {
93
+ private profile: ReadingProfile | undefined;
94
+ /** The last optional, marker-less slot that read a value: what a failing required slot may take back. */
95
+ private lastOptional: { role: SemanticRole; start: StreamMark; end: number } | undefined;
96
+
97
+ constructor(
98
+ private readonly view: MatchView,
99
+ private readonly profiles: Readonly<Record<string, ReadingProfile>> = {}
100
+ ) {}
101
+
102
+ beginPattern(pattern: LanguagePattern): void {
103
+ this.profile = this.profiles[pattern.language] ?? { code: pattern.language };
104
+ this.lastOptional = undefined;
105
+ }
106
+
107
+ readRole(
108
+ tokens: TokenStream,
109
+ slot: RoleSlot,
110
+ captured: Map<SemanticRole, SemanticValue>,
111
+ next: PatternToken | undefined
112
+ ): boolean {
113
+ const start = tokens.mark();
114
+ if (this.readOnce(tokens, slot, captured, next, true)) {
115
+ if (!slot.optional) {
116
+ this.lastOptional = undefined;
117
+ } else if (
118
+ captured.has(slot.role) &&
119
+ tokens.position() > start.position &&
120
+ !this.view.slotAfterLiteral() &&
121
+ next?.type !== 'literal'
122
+ ) {
123
+ this.lastOptional = { role: slot.role, start, end: tokens.position() };
124
+ }
125
+ return true;
126
+ }
127
+ const before = this.lastOptional;
128
+ if (slot.optional || !before || before.end !== tokens.position()) return false;
129
+ // Give the optional slot's value back and read this one from there.
130
+ this.lastOptional = undefined;
131
+ tokens.reset(before.start);
132
+ captured.delete(before.role);
133
+ return this.readOnce(tokens, slot, captured, next, false);
134
+ }
135
+
136
+ /** One read of `slot`; `guard` refuses a required slot the marker the pattern wants next. */
137
+ private readOnce(
138
+ tokens: TokenStream,
139
+ slot: RoleSlot,
140
+ captured: Map<SemanticRole, SemanticValue>,
141
+ next: PatternToken | undefined,
142
+ guard: boolean
143
+ ): boolean {
144
+ // A required slot never takes the marker the pattern wants next as its
145
+ // value (`v0x を 実行`: `を` is the marker, not the patient).
146
+ if (guard && !slot.optional && next?.type === 'literal') {
147
+ const token = tokens.peek();
148
+ if (token && this.view.wouldMatch(next, token)) return false;
149
+ }
150
+ if (slot.greedy) {
151
+ const value = readGreedy(tokens, greedyStopMarkers(this.view.patternRest()));
152
+ if (value) {
153
+ captured.set(slot.role, value);
154
+ return true;
155
+ }
156
+ return slot.optional ?? false;
157
+ }
158
+ return readRoleValue(tokens, slot, captured, this.profile);
159
+ }
160
+
161
+ maySkipSlot(
162
+ patternToken: PatternToken,
163
+ token: LanguageToken | null,
164
+ next: PatternToken | undefined
165
+ ): boolean {
166
+ const slot =
167
+ patternToken.type === 'group' && patternToken.optional
168
+ ? patternToken.tokens.every(t => t.type === 'role')
169
+ ? patternToken.tokens[0]
170
+ : undefined
171
+ : patternToken;
172
+ if (!slot || slot.type !== 'role' || !slot.optional) return false;
173
+ return !!token && this.view.wouldMatch(next, token);
174
+ }
175
+
176
+ atClauseEnd(tokens: TokenStream): boolean {
177
+ return tokens.isAtEnd();
178
+ }
179
+ }
180
+
181
+ /** A reader factory for `MatchSkeleton`, reading with the given profiles. */
182
+ export function createStockReader(
183
+ profiles: Readonly<Record<string, ReadingProfile>> = {}
184
+ ): (view: MatchView) => ValueReader {
185
+ return view => new StockValueReader(view, profiles);
186
+ }