@lokascript/framework 2.8.0 → 2.9.1

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 (48) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/dist/api/create-dsl.d.ts +93 -1
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/domain-registry.d.ts +5 -3
  5. package/dist/api/domain-registry.d.ts.map +1 -1
  6. package/dist/api/index.js +232 -18
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/core/index.js +26 -6
  9. package/dist/core/index.js.map +1 -1
  10. package/dist/core/tokenization/base-tokenizer.d.ts +15 -3
  11. package/dist/core/tokenization/base-tokenizer.d.ts.map +1 -1
  12. package/dist/core/tokenization/char-classifiers.d.ts +2 -2
  13. package/dist/core/tokenization/index.js +26 -6
  14. package/dist/core/tokenization/index.js.map +1 -1
  15. package/dist/core/tokenization/token-utils.d.ts +15 -0
  16. package/dist/core/tokenization/token-utils.d.ts.map +1 -1
  17. package/dist/generation/index.js +73 -47
  18. package/dist/generation/index.js.map +1 -1
  19. package/dist/generation/pattern-generator.d.ts +8 -1
  20. package/dist/generation/pattern-generator.d.ts.map +1 -1
  21. package/dist/generation/renderer.d.ts +53 -1
  22. package/dist/generation/renderer.d.ts.map +1 -1
  23. package/dist/index.cjs +302 -115
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.ts +4 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +298 -115
  28. package/dist/index.js.map +1 -1
  29. package/dist/interfaces/value-extractor.d.ts +5 -0
  30. package/dist/interfaces/value-extractor.d.ts.map +1 -1
  31. package/dist/multilingual/index.js +25 -6
  32. package/dist/multilingual/index.js.map +1 -1
  33. package/package.json +4 -3
  34. package/src/api/create-dsl.test.ts +11 -0
  35. package/src/api/create-dsl.ts +278 -9
  36. package/src/api/domain-registry.ts +15 -10
  37. package/src/api/extensions.test.ts +322 -0
  38. package/src/core/tokenization/base-tokenizer.ts +23 -8
  39. package/src/core/tokenization/char-classifiers.ts +2 -2
  40. package/src/core/tokenization/css-selector-extractor.test.ts +67 -0
  41. package/src/core/tokenization/token-utils.ts +18 -0
  42. package/src/generation/domain-renderer.test.ts +172 -0
  43. package/src/generation/pattern-generator.test.ts +102 -0
  44. package/src/generation/pattern-generator.ts +27 -19
  45. package/src/generation/renderer.test.ts +243 -4
  46. package/src/generation/renderer.ts +188 -45
  47. package/src/index.ts +9 -1
  48. package/src/interfaces/value-extractor.ts +50 -0
@@ -71,6 +71,108 @@ describe('PatternGenerator', () => {
71
71
  expect(pattern.template.tokens[1]).toEqual({ type: 'literal', value: 'トグル' });
72
72
  });
73
73
 
74
+ it('should place a sovSlot:postVerb role after the verb in SOV', () => {
75
+ // The renderer buckets by this field (`renderer.ts`), writing voice's
76
+ // page count after the verb: `戻る 2`. Generation has to agree, or the
77
+ // rendered surface re-parses as a bare verb with its argument dropped.
78
+ const schema = defineCommand({
79
+ action: 'back',
80
+ roles: [
81
+ defineRole({
82
+ role: 'quantity',
83
+ required: true,
84
+ expectedTypes: ['expression'],
85
+ sovPosition: 1,
86
+ sovSlot: 'postVerb',
87
+ }),
88
+ ],
89
+ });
90
+
91
+ const profile: PatternGenLanguageProfile = {
92
+ code: 'ja',
93
+ wordOrder: 'SOV',
94
+ keywords: { back: { primary: '戻る' } },
95
+ };
96
+
97
+ const pattern = generatePattern(schema, profile);
98
+
99
+ expect(pattern.template.tokens).toHaveLength(2);
100
+ expect(pattern.template.tokens[0]).toEqual({ type: 'literal', value: '戻る' });
101
+ expect(pattern.template.tokens[1]).toMatchObject({ type: 'role', role: 'quantity' });
102
+ expect(pattern.template.format).toBe('戻る {quantity}');
103
+ });
104
+
105
+ it('should split pre-verb and post-verb roles around the verb in SOV', () => {
106
+ const schema = defineCommand({
107
+ action: 'fetch',
108
+ roles: [
109
+ defineRole({
110
+ role: 'source',
111
+ required: true,
112
+ expectedTypes: ['expression'],
113
+ sovPosition: 2,
114
+ }),
115
+ defineRole({
116
+ role: 'destination',
117
+ required: true,
118
+ expectedTypes: ['selector'],
119
+ sovPosition: 1,
120
+ sovSlot: 'postVerb',
121
+ markerOverride: { ja: 'に' },
122
+ }),
123
+ ],
124
+ });
125
+
126
+ const profile: PatternGenLanguageProfile = {
127
+ code: 'ja',
128
+ wordOrder: 'SOV',
129
+ keywords: { fetch: { primary: '取得' } },
130
+ };
131
+
132
+ const pattern = generatePattern(schema, profile);
133
+
134
+ expect(pattern.template.tokens).toEqual([
135
+ expect.objectContaining({ type: 'role', role: 'source' }),
136
+ { type: 'literal', value: '取得' },
137
+ expect.objectContaining({ type: 'role', role: 'destination' }),
138
+ // SOV markers default to following their value
139
+ { type: 'literal', value: 'に' },
140
+ ]);
141
+ // The role PHRASE moves as a unit. Note the marker sits before its role
142
+ // here while the tokens put it after: `format` resolves marker position
143
+ // from the profile alone, missing `markerPosition` and the SOV default
144
+ // that `addRoleWithMarker` applies. Pre-existing and documentation-only
145
+ // (nothing reads `format`), left alone deliberately — fixing it would
146
+ // move every SOV golden entry with a marker and bury this change's diff.
147
+ expect(pattern.template.format).toBe('{source} 取得 に {destination}');
148
+ });
149
+
150
+ it('should ignore sovSlot outside SOV languages', () => {
151
+ const schema = defineCommand({
152
+ action: 'back',
153
+ roles: [
154
+ defineRole({
155
+ role: 'quantity',
156
+ required: true,
157
+ expectedTypes: ['expression'],
158
+ svoPosition: 1,
159
+ sovSlot: 'postVerb',
160
+ }),
161
+ ],
162
+ });
163
+
164
+ for (const wordOrder of ['SVO', 'VSO'] as const) {
165
+ const pattern = generatePattern(schema, {
166
+ code: 'en',
167
+ wordOrder,
168
+ keywords: { back: { primary: 'back' } },
169
+ });
170
+
171
+ expect(pattern.template.tokens[0]).toEqual({ type: 'literal', value: 'back' });
172
+ expect(pattern.template.tokens[1]).toMatchObject({ type: 'role', role: 'quantity' });
173
+ }
174
+ });
175
+
74
176
  it('should generate a VSO pattern with verb first', () => {
75
177
  const schema = defineCommand({
76
178
  action: 'toggle',
@@ -110,15 +110,21 @@ function buildTokens(
110
110
  addRoleWithMarker(tokens, role, profile);
111
111
  }
112
112
  }
113
- // For SOV: [patient, destination, source, ..., action]
113
+ // For SOV: [patient, destination, source, ..., action, postVerb roles...]
114
114
  else if (profile.wordOrder === 'SOV') {
115
- // Add roles with markers first
115
+ // `sovSlot: 'postVerb'` moves a role AFTER the verb, which is what the
116
+ // renderer does (`renderer.ts` buckets by the same field). Generation has
117
+ // to mirror it: a role the renderer writes post-verb but the pattern
118
+ // expects pre-verb produces a faithful surface its own parser drops the
119
+ // argument from — voice's `戻る 2` re-parsing as a bare `back`.
120
+ const preVerb: PatternToken[] = [];
121
+ const postVerb: PatternToken[] = [];
122
+
116
123
  for (const role of sortedRoles) {
117
- addRoleWithMarker(tokens, role, profile);
124
+ addRoleWithMarker(role.sovSlot === 'postVerb' ? postVerb : preVerb, role, profile);
118
125
  }
119
126
 
120
- // Add keyword last
121
- tokens.push(keywordToken);
127
+ tokens.push(...preVerb, keywordToken, ...postVerb);
122
128
  }
123
129
  // For VSO: [action, destination, patient, source, ...]
124
130
  else if (profile.wordOrder === 'VSO') {
@@ -218,8 +224,11 @@ function getMarkerForRole(
218
224
 
219
225
  /**
220
226
  * Sort roles by word order for pattern building.
227
+ *
228
+ * Exported so the renderer sorts with exactly this comparator — a rendered
229
+ * surface whose role order differs from the generated pattern would not re-parse.
221
230
  */
222
- function sortRolesByWordOrder(roles: RoleSpec[], wordOrder: string): RoleSpec[] {
231
+ export function sortRolesByWordOrder(roles: RoleSpec[], wordOrder: string): RoleSpec[] {
223
232
  const sorted = [...roles];
224
233
 
225
234
  if (wordOrder === 'SVO') {
@@ -266,11 +275,9 @@ function buildFormatString(
266
275
  profile: PatternGenLanguageProfile,
267
276
  keyword: string
268
277
  ): string {
269
- const parts: string[] = [];
270
-
271
- if (profile.wordOrder === 'SVO' || profile.wordOrder === 'VSO') {
272
- parts.push(keyword);
273
- }
278
+ const isSOV = profile.wordOrder === 'SOV';
279
+ const preVerb: string[] = [];
280
+ const postVerb: string[] = [];
274
281
 
275
282
  // Order roles the same way buildTokens does (descending position). Iterating
276
283
  // declaration order here would disagree with the token order whenever a
@@ -279,24 +286,25 @@ function buildFormatString(
279
286
  for (const role of sortedRoles) {
280
287
  const marker = getMarkerForRole(role, profile);
281
288
  const roleName = `{${role.role}}`;
289
+ // Same pre/post-verb split buildTokens applies, so the documented format
290
+ // string keeps describing the tokens it is generated alongside.
291
+ const bucket = isSOV && role.sovSlot === 'postVerb' ? postVerb : preVerb;
282
292
 
283
293
  if (marker) {
284
294
  const markerInfo = profile.roleMarkers?.[role.role];
285
295
  if (markerInfo?.position === 'after') {
286
- parts.push(`${roleName} ${marker}`);
296
+ bucket.push(`${roleName} ${marker}`);
287
297
  } else {
288
- parts.push(`${marker} ${roleName}`);
298
+ bucket.push(`${marker} ${roleName}`);
289
299
  }
290
300
  } else {
291
- parts.push(roleName);
301
+ bucket.push(roleName);
292
302
  }
293
303
  }
294
304
 
295
- if (profile.wordOrder === 'SOV') {
296
- parts.push(keyword);
297
- }
298
-
299
- return parts.join(' ');
305
+ return (isSOV ? [...preVerb, keyword, ...postVerb] : [keyword, ...preVerb, ...postVerb]).join(
306
+ ' '
307
+ );
300
308
  }
301
309
 
302
310
  /**
@@ -168,6 +168,8 @@ describe('detectWordOrders', () => {
168
168
  // =============================================================================
169
169
 
170
170
  describe('createSchemaRenderer', () => {
171
+ // Positions sort DESCENDING (higher = earlier), matching pattern generation:
172
+ // columns (2) precedes source (1) → "select name from users".
171
173
  const schemas: CommandSchema[] = [
172
174
  defineCommand({
173
175
  action: 'select',
@@ -179,13 +181,13 @@ describe('createSchemaRenderer', () => {
179
181
  role: 'columns',
180
182
  required: true,
181
183
  expectedTypes: ['expression'],
182
- svoPosition: 1,
184
+ svoPosition: 2,
183
185
  }),
184
186
  defineRole({
185
187
  role: 'source',
186
188
  required: true,
187
189
  expectedTypes: ['expression'],
188
- svoPosition: 2,
190
+ svoPosition: 1,
189
191
  markerOverride: { en: 'from', ja: 'から' },
190
192
  }),
191
193
  ],
@@ -239,7 +241,7 @@ describe('createSchemaRenderer', () => {
239
241
  role: 'columns',
240
242
  required: true,
241
243
  expectedTypes: ['expression'],
242
- svoPosition: 1,
244
+ svoPosition: 3,
243
245
  }),
244
246
  defineRole({
245
247
  role: 'source',
@@ -252,7 +254,7 @@ describe('createSchemaRenderer', () => {
252
254
  role: 'condition',
253
255
  required: false,
254
256
  expectedTypes: ['expression'],
255
- svoPosition: 3,
257
+ svoPosition: 1,
256
258
  markerOverride: { en: 'where' },
257
259
  }),
258
260
  ],
@@ -263,4 +265,241 @@ describe('createSchemaRenderer', () => {
263
265
  const node = makeNode('select', { columns: 'name', source: 'users' });
264
266
  expect(renderer.render(node, 'en')).toBe('select name from users');
265
267
  });
268
+
269
+ describe('absent roles never leave a dangling marker', () => {
270
+ // An "analyze"-shaped schema: `manner` is REQUIRED and marked, so a node
271
+ // missing it used to render "analyze #content as" / "#content として 分析".
272
+ const analyzeSchemas: CommandSchema[] = [
273
+ defineCommand({
274
+ action: 'analyze',
275
+ description: 'Analyze content',
276
+ category: 'llm',
277
+ primaryRole: 'patient',
278
+ roles: [
279
+ defineRole({
280
+ role: 'patient',
281
+ required: true,
282
+ expectedTypes: ['expression'],
283
+ svoPosition: 2,
284
+ }),
285
+ defineRole({
286
+ role: 'manner',
287
+ required: true,
288
+ expectedTypes: ['expression'],
289
+ svoPosition: 1,
290
+ markerOverride: { en: 'as', ja: 'として' },
291
+ }),
292
+ ],
293
+ }),
294
+ ];
295
+
296
+ const analyzeProfiles: PatternGenLanguageProfile[] = [
297
+ {
298
+ code: 'en',
299
+ wordOrder: 'SVO',
300
+ keywords: { analyze: { primary: 'analyze' } },
301
+ roleMarkers: {},
302
+ },
303
+ { code: 'ja', wordOrder: 'SOV', keywords: { analyze: { primary: '分析' } }, roleMarkers: {} },
304
+ ];
305
+
306
+ it('omits a required-but-absent role and its marker (SVO)', () => {
307
+ const renderer = createSchemaRenderer(analyzeSchemas, analyzeProfiles);
308
+ const node = makeNode('analyze', { patient: '#content' });
309
+ expect(renderer.render(node, 'en')).toBe('analyze #content');
310
+ });
311
+
312
+ it('omits a required-but-absent role and its marker (SOV)', () => {
313
+ const renderer = createSchemaRenderer(analyzeSchemas, analyzeProfiles);
314
+ const node = makeNode('analyze', { patient: '#content' });
315
+ expect(renderer.render(node, 'ja')).toBe('#content 分析');
316
+ });
317
+
318
+ it('still renders the marker when the role has a value', () => {
319
+ const renderer = createSchemaRenderer(analyzeSchemas, analyzeProfiles);
320
+ const node = makeNode('analyze', { patient: '#content', manner: 'sentiment' });
321
+ expect(renderer.render(node, 'en')).toBe('analyze #content as sentiment');
322
+ });
323
+ });
324
+
325
+ describe('role positions', () => {
326
+ // Declaration order deliberately disagrees with the declared positions.
327
+ const outOfOrder: CommandSchema[] = [
328
+ defineCommand({
329
+ action: 'select',
330
+ description: 'Select data',
331
+ category: 'query',
332
+ primaryRole: 'columns',
333
+ roles: [
334
+ defineRole({
335
+ role: 'source',
336
+ required: true,
337
+ expectedTypes: ['expression'],
338
+ svoPosition: 1,
339
+ sovPosition: 1,
340
+ markerOverride: { en: 'from', ja: 'から' },
341
+ }),
342
+ defineRole({
343
+ role: 'columns',
344
+ required: true,
345
+ expectedTypes: ['expression'],
346
+ svoPosition: 2,
347
+ sovPosition: 2,
348
+ }),
349
+ ],
350
+ }),
351
+ ];
352
+
353
+ it('orders roles by svoPosition, not declaration order', () => {
354
+ const renderer = createSchemaRenderer(outOfOrder, profiles);
355
+ const node = makeNode('select', { columns: 'name', source: 'users' });
356
+ expect(renderer.render(node, 'en')).toBe('select name from users');
357
+ });
358
+
359
+ it('orders roles by sovPosition for SOV languages', () => {
360
+ const renderer = createSchemaRenderer(outOfOrder, profiles);
361
+ const node = makeNode('select', { columns: 'name', source: 'users' });
362
+ expect(renderer.render(node, 'ja')).toBe('name users から 選択');
363
+ });
364
+ });
365
+
366
+ describe('role defaults', () => {
367
+ const withDefault: CommandSchema[] = [
368
+ defineCommand({
369
+ action: 'select',
370
+ description: 'Select data',
371
+ category: 'query',
372
+ primaryRole: 'columns',
373
+ roles: [
374
+ defineRole({
375
+ role: 'columns',
376
+ required: true,
377
+ expectedTypes: ['expression'],
378
+ svoPosition: 2,
379
+ default: { type: 'literal', value: '*' },
380
+ }),
381
+ defineRole({
382
+ role: 'source',
383
+ required: true,
384
+ expectedTypes: ['expression'],
385
+ svoPosition: 1,
386
+ markerOverride: { en: 'from' },
387
+ }),
388
+ ],
389
+ }),
390
+ ];
391
+
392
+ it("renders a schema default in an absent role's place", () => {
393
+ const renderer = createSchemaRenderer(withDefault, profiles);
394
+ const node = makeNode('select', { source: 'users' });
395
+ expect(renderer.render(node, 'en')).toBe('select * from users');
396
+ });
397
+
398
+ it('prefers the node value over the default', () => {
399
+ const renderer = createSchemaRenderer(withDefault, profiles);
400
+ const node = makeNode('select', { columns: 'name', source: 'users' });
401
+ expect(renderer.render(node, 'en')).toBe('select name from users');
402
+ });
403
+ });
404
+
405
+ describe('marker placement and formatting', () => {
406
+ const capabilities: CommandSchema[] = [
407
+ defineCommand({
408
+ action: 'summarize',
409
+ description: 'Summarize content',
410
+ category: 'llm',
411
+ primaryRole: 'patient',
412
+ roles: [
413
+ defineRole({
414
+ role: 'patient',
415
+ required: true,
416
+ expectedTypes: ['expression'],
417
+ svoPosition: 3,
418
+ sovPosition: 3,
419
+ quoteMultiword: true,
420
+ }),
421
+ defineRole({
422
+ role: 'quantity',
423
+ required: false,
424
+ expectedTypes: ['expression'],
425
+ svoPosition: 2,
426
+ sovPosition: 2,
427
+ markerOverride: { en: 'in', ja: 'で' },
428
+ // Japanese renders this marker BEFORE its value despite SOV default
429
+ markerPositionOverride: { ja: 'before' },
430
+ }),
431
+ defineRole({
432
+ role: 'manner',
433
+ required: false,
434
+ expectedTypes: ['expression'],
435
+ svoPosition: 1,
436
+ sovPosition: 1,
437
+ markerOverride: { en: 'as', ja: 'として' },
438
+ sovSlot: 'postVerb',
439
+ }),
440
+ defineRole({
441
+ role: 'source',
442
+ required: false,
443
+ expectedTypes: ['expression'],
444
+ svoPosition: 0,
445
+ markerOverride: { en: '' }, // parser-only marker; renders bare
446
+ }),
447
+ ],
448
+ }),
449
+ ];
450
+
451
+ const capProfiles: PatternGenLanguageProfile[] = [
452
+ {
453
+ code: 'en',
454
+ wordOrder: 'SVO',
455
+ keywords: { summarize: { primary: 'summarize' } },
456
+ roleMarkers: {},
457
+ },
458
+ {
459
+ code: 'ja',
460
+ wordOrder: 'SOV',
461
+ keywords: { summarize: { primary: '要約' } },
462
+ roleMarkers: {},
463
+ },
464
+ ];
465
+
466
+ it('quotes a multi-word value for quoteMultiword roles', () => {
467
+ const renderer = createSchemaRenderer(capabilities, capProfiles);
468
+ const node = makeNode('summarize', { patient: 'the annual report' });
469
+ expect(renderer.render(node, 'en')).toBe('summarize "the annual report"');
470
+ });
471
+
472
+ it('leaves a single-word value unquoted', () => {
473
+ const renderer = createSchemaRenderer(capabilities, capProfiles);
474
+ const node = makeNode('summarize', { patient: '#document' });
475
+ expect(renderer.render(node, 'en')).toBe('summarize #document');
476
+ });
477
+
478
+ it('does not double-quote an already-quoted value', () => {
479
+ const renderer = createSchemaRenderer(capabilities, capProfiles);
480
+ const node = makeNode('summarize', { patient: '"the annual report"' });
481
+ expect(renderer.render(node, 'en')).toBe('summarize "the annual report"');
482
+ });
483
+
484
+ it('honors markerPositionOverride against the word-order default', () => {
485
+ const renderer = createSchemaRenderer(capabilities, capProfiles);
486
+ const node = makeNode('summarize', { patient: '#doc', quantity: '3' });
487
+ // ja default would be "3 で"; the override puts the marker first
488
+ expect(renderer.render(node, 'ja')).toBe('#doc で 3 要約');
489
+ });
490
+
491
+ it('renders a sovSlot:postVerb role after the verb', () => {
492
+ const renderer = createSchemaRenderer(capabilities, capProfiles);
493
+ const node = makeNode('summarize', { patient: '#doc', manner: 'bullets' });
494
+ expect(renderer.render(node, 'ja')).toBe('#doc 要約 bullets として');
495
+ // SVO is unaffected — sovSlot applies only to SOV languages
496
+ expect(renderer.render(node, 'en')).toBe('summarize #doc as bullets');
497
+ });
498
+
499
+ it("treats renderOverride '' as no marker", () => {
500
+ const renderer = createSchemaRenderer(capabilities, capProfiles);
501
+ const node = makeNode('summarize', { patient: '#doc', source: '#feed' });
502
+ expect(renderer.render(node, 'en')).toBe('summarize #doc #feed');
503
+ });
504
+ });
266
505
  });