@lokascript/framework 4.5.0 → 4.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lokascript/framework",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "Generic framework for building multilingual DSLs with semantic parsing and grammar transformation",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -96,10 +96,10 @@
96
96
  "author": "LokaScript Contributors",
97
97
  "license": "MIT",
98
98
  "dependencies": {
99
- "@lokascript/intent": "^4.5.0"
99
+ "@lokascript/intent": "^4.6.0"
100
100
  },
101
101
  "devDependencies": {
102
- "@lokascript/domains": "^3.0.1",
102
+ "@lokascript/domains": "^4.0.0",
103
103
  "@types/node": "^26.6.3",
104
104
  "@vitest/coverage-v8": "^5.0.0",
105
105
  "tsup": "^8.0.0",
@@ -47,6 +47,9 @@ export const selectCommand = defineCommand({
47
47
  description: 'WHERE clause condition',
48
48
  required: false,
49
49
  expectedTypes: ['expression'],
50
+ // As in domain-sql: the condition is the rest of the input (`age > 18`). Without it the
51
+ // condition read `age` and dropped `> 18`, which parse accepted until it refused unread input.
52
+ greedy: true,
50
53
  svoPosition: 0,
51
54
  sovPosition: 0,
52
55
  markerOverride: { en: 'where', es: 'donde' },
@@ -46,8 +46,8 @@ describe('SQL DSL Integration', () => {
46
46
  const condition = result.roles.get('condition');
47
47
  expect(condition).toBeDefined();
48
48
  if (condition && 'raw' in condition) {
49
- // The condition role captures the expression after WHERE
50
- expect(condition.raw).toBeTruthy();
49
+ // The condition role captures the whole expression after WHERE
50
+ expect(condition.raw).toBe('age > 18');
51
51
  }
52
52
  });
53
53
  });
@@ -13,6 +13,8 @@ import {
13
13
  defineRole,
14
14
  BaseTokenizer,
15
15
  getDefaultExtractors,
16
+ UnreadInputError,
17
+ TranslationReadBackError,
16
18
  type SemanticNode,
17
19
  } from '../index';
18
20
 
@@ -156,6 +158,35 @@ function createBilingualTestDSL() {
156
158
  });
157
159
  }
158
160
 
161
+ /** The bilingual DSL with a custom renderer (and, optionally, a code generator). */
162
+ function createBilingualTestDSLWith(
163
+ renderer: (node: SemanticNode, language: string) => string | null,
164
+ codeGenerator?: { generate(node: SemanticNode): string }
165
+ ) {
166
+ return createMultilingualDSL({
167
+ name: 'TestSQL-rendered',
168
+ schemas: [selectSchema, insertSchema],
169
+ languages: [
170
+ {
171
+ code: 'en',
172
+ name: 'English',
173
+ nativeName: 'English',
174
+ tokenizer: new EnglishTestTokenizer(),
175
+ patternProfile: englishProfile,
176
+ },
177
+ {
178
+ code: 'es',
179
+ name: 'Spanish',
180
+ nativeName: 'Español',
181
+ tokenizer: new SpanishTestTokenizer(),
182
+ patternProfile: spanishProfile,
183
+ },
184
+ ],
185
+ renderer,
186
+ ...(codeGenerator ? { codeGenerator } : {}),
187
+ });
188
+ }
189
+
159
190
  function createTestDSL() {
160
191
  return createMultilingualDSL({
161
192
  name: 'TestSQL',
@@ -396,4 +427,93 @@ describe('MultilingualDSL: Explicit Syntax Support', () => {
396
427
  expect(result.code).toContain('SELECT');
397
428
  });
398
429
  });
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Input left unread (DOMAINS_RETHINK.md, Phase 3)
433
+ // ---------------------------------------------------------------------------
434
+
435
+ describe('input left unread', () => {
436
+ it('refuses a parse that reads the command only by dropping what follows it', () => {
437
+ const dsl = createTestDSL();
438
+ let error: unknown;
439
+ try {
440
+ dsl.parse('select name from users banana', 'en');
441
+ } catch (e) {
442
+ error = e;
443
+ }
444
+ expect(error).toBeInstanceOf(UnreadInputError);
445
+ const refusal = error as UnreadInputError;
446
+ expect(refusal.code).toBe('unread-input');
447
+ expect(refusal.unread).toBe('banana');
448
+ expect(refusal.reading.action).toBe('select');
449
+ expect(refusal.message).toMatch(/leaving "banana" unread/);
450
+ });
451
+
452
+ it('compile and validate report the refusal instead of compiling the shorter reading', () => {
453
+ const dsl = createTestDSL();
454
+ const compiled = dsl.compile('select name from users banana banana', 'en');
455
+ expect(compiled.ok).toBe(false);
456
+ expect(compiled.errors?.[0]).toMatch(/leaving "banana banana" unread/);
457
+ expect(dsl.validate('select name from users banana', 'en').valid).toBe(false);
458
+ });
459
+
460
+ it('translate refuses too, rather than translating the shorter reading', () => {
461
+ const dsl = createBilingualTestDSL();
462
+ expect(() => dsl.translate('select name from users banana', 'en', 'es')).toThrow(
463
+ UnreadInputError
464
+ );
465
+ });
466
+
467
+ it('a translation that does not read back is refused, unless { lossy: "allow" }', () => {
468
+ // A renderer that drops the required source: the Spanish does not parse at all.
469
+ const dropsSource = createBilingualTestDSLWith(node =>
470
+ node.action === 'select' ? 'seleccionar nombre' : null
471
+ );
472
+ let error: unknown;
473
+ try {
474
+ dropsSource.translate('select nombre from users', 'en', 'es');
475
+ } catch (e) {
476
+ error = e;
477
+ }
478
+ expect(error).toBeInstanceOf(TranslationReadBackError);
479
+ const refusal = error as TranslationReadBackError;
480
+ expect(refusal.code).toBe('translation-does-not-read-back');
481
+ expect(refusal.partial).toBe('seleccionar nombre');
482
+ expect(refusal.reading).toBeNull();
483
+ expect(
484
+ dropsSource.translate('select nombre from users', 'en', 'es', { lossy: 'allow' })
485
+ ).toBe('seleccionar nombre');
486
+ });
487
+
488
+ it('a translation that reads with a value changed is refused, naming the role', () => {
489
+ const changesSource = createBilingualTestDSLWith(node =>
490
+ node.action === 'select' ? 'seleccionar nombre de clientes' : null
491
+ );
492
+ expect(() => changesSource.translate('select nombre from users', 'en', 'es')).toThrow(
493
+ /source: users → clientes/
494
+ );
495
+ });
496
+
497
+ it('a difference the code generator normalizes still reads back', () => {
498
+ // Upper case in the render, lower case in the generator: the nodes differ, the code does not.
499
+ const shouts = createBilingualTestDSLWith(
500
+ node => (node.action === 'select' ? 'seleccionar NOMBRE de USERS' : null),
501
+ {
502
+ generate: node =>
503
+ `${node.action} ${[...node.roles.values()]
504
+ .map(v => String((v as { raw?: unknown }).raw ?? '').toLowerCase())
505
+ .join(' ')}`,
506
+ }
507
+ );
508
+ expect(shouts.translate('select nombre from users', 'en', 'es')).toBe(
509
+ 'seleccionar NOMBRE de USERS'
510
+ );
511
+ });
512
+
513
+ it('input read whole still parses, and nothing matching at all is still "No pattern matched"', () => {
514
+ const dsl = createTestDSL();
515
+ expect(dsl.parse('select name from users', 'en').action).toBe('select');
516
+ expect(() => dsl.parse('banana', 'en')).toThrow(/No pattern matched/);
517
+ });
518
+ });
399
519
  });
@@ -2,7 +2,12 @@
2
2
  * Main API for creating multilingual DSLs
3
3
  */
4
4
 
5
- import type { SemanticNode, LanguageTokenizer, LanguagePattern } from '../core/types';
5
+ import type {
6
+ SemanticNode,
7
+ LanguageTokenizer,
8
+ LanguagePattern,
9
+ PatternMatchResult,
10
+ } from '../core/types';
6
11
  import type { CommandSchema } from '../schema';
7
12
  import { PatternMatcher, type PatternMatcherProfile } from '../core/pattern-matching';
8
13
  import { generatePattern, type PatternGenLanguageProfile } from '../generation/pattern-generator';
@@ -16,6 +21,7 @@ import {
16
21
  InMemoryProfileProvider,
17
22
  } from '../interfaces';
18
23
  import { isExplicitSyntax, parseExplicit } from '../ir';
24
+ import { readingDifference } from './reading';
19
25
  import type { SchemaLookup } from '../ir';
20
26
 
21
27
  /**
@@ -177,6 +183,8 @@ export interface ValidationResult {
177
183
  readonly valid: boolean;
178
184
  readonly node?: SemanticNode;
179
185
  readonly errors?: string[];
186
+ /** Set when the input was refused for a known reason: `'unread-input'` (`UnreadInputError`). */
187
+ readonly errorCode?: string;
180
188
  }
181
189
 
182
190
  /**
@@ -186,6 +194,8 @@ export interface CompileResult {
186
194
  readonly ok: boolean;
187
195
  readonly code?: string;
188
196
  readonly errors?: string[];
197
+ /** Set when the input was refused for a known reason: `'unread-input'` (`UnreadInputError`). */
198
+ readonly errorCode?: string;
189
199
  readonly node?: SemanticNode;
190
200
  readonly metadata?: {
191
201
  readonly parser: string;
@@ -193,6 +203,55 @@ export interface CompileResult {
193
203
  };
194
204
  }
195
205
 
206
+ /**
207
+ * Thrown by `parse` (and so `compile`, `validate`, `translate`) when the input reads as a command
208
+ * only by leaving some of it unread: `select name from users banana` is not `select name from users`.
209
+ * `reading` is what the longest such match read, `unread` the text after it.
210
+ */
211
+ export class UnreadInputError extends Error {
212
+ readonly code = 'unread-input';
213
+
214
+ constructor(
215
+ readonly input: string,
216
+ readonly language: string,
217
+ readonly reading: SemanticNode,
218
+ readonly unread: string
219
+ ) {
220
+ super(`"${input}" (${language}) reads as ${reading.action} only by leaving "${unread}" unread`);
221
+ this.name = 'UnreadInputError';
222
+ }
223
+ }
224
+
225
+ /** Options for `translate`. */
226
+ export interface TranslateOptions {
227
+ /**
228
+ * `'allow'` returns a translation that does not read back as the source instead of throwing
229
+ * `TranslationReadBackError`, as semantic's `translate` does with the same option.
230
+ */
231
+ readonly lossy?: 'allow' | 'refuse';
232
+ }
233
+
234
+ /**
235
+ * Thrown by `translate` when its output, read in the target language, is not the source command:
236
+ * it does not read at all, or reads as another command, or with a role lost or changed. `partial` is
237
+ * what `translate` would have returned; `reading` what it reads as (null when it does not read).
238
+ */
239
+ export class TranslationReadBackError extends Error {
240
+ readonly code = 'translation-does-not-read-back';
241
+
242
+ constructor(
243
+ readonly partial: string,
244
+ readonly from: string,
245
+ readonly to: string,
246
+ readonly source: SemanticNode,
247
+ readonly reading: SemanticNode | null,
248
+ readonly why: string
249
+ ) {
250
+ super(`translation ${from} → ${to} does not read back as the source (${why}): "${partial}"`);
251
+ this.name = 'TranslationReadBackError';
252
+ }
253
+ }
254
+
196
255
  /**
197
256
  * Main multilingual DSL interface.
198
257
  */
@@ -208,7 +267,17 @@ export interface MultilingualDSL {
208
267
  compile(input: string, language: string): CompileResult;
209
268
 
210
269
  // Translation
211
- translate(input: string, fromLanguage: string, toLanguage: string): string;
270
+ /**
271
+ * Parse in one language, render in another. The output must read back as the source command
272
+ * (the same action and role values, or the same compiled code); when it does not,
273
+ * `TranslationReadBackError`, unless `{ lossy: 'allow' }`.
274
+ */
275
+ translate(
276
+ input: string,
277
+ fromLanguage: string,
278
+ toLanguage: string,
279
+ options?: TranslateOptions
280
+ ): string;
212
281
 
213
282
  /**
214
283
  * Render a parsed node back to natural language.
@@ -366,30 +435,66 @@ class MultilingualDSLImpl implements MultilingualDSL {
366
435
  code: language,
367
436
  };
368
437
 
369
- // Try to match each pattern
438
+ // Every pattern is tried, and only a match that reads the WHOLE input counts; of those, the
439
+ // highest priority wins, then the highest confidence, then the earliest pattern. The first match
440
+ // used to win however much it read, so a trailing word was dropped without a word (the domain
441
+ // gate measured 1,744 of 1,744 such inputs accepted) and an earlier, shorter pattern could
442
+ // shadow the one that reads everything.
443
+ const total = tokens.tokens.length;
444
+ let best: PatternMatchResult | null = null;
445
+ let longestPartial: PatternMatchResult | null = null;
370
446
  for (const pattern of patterns) {
447
+ const mark = tokens.mark();
371
448
  const match = this.matcher.matchPattern(tokens, pattern, profile);
372
- if (match) {
373
- // Build semantic node
374
- const node: SemanticNode = {
375
- kind: 'command',
376
- action: pattern.command,
377
- roles: match.captured,
378
- metadata: {
379
- sourceLanguage: language,
380
- sourceText: input,
381
- patternId: pattern.id,
382
- confidence: match.confidence,
383
- },
384
- };
385
-
386
- return { node, confidence: match.confidence };
449
+ tokens.reset(mark);
450
+ if (!match) continue;
451
+ if (match.consumedTokens < total) {
452
+ if (!longestPartial || match.consumedTokens > longestPartial.consumedTokens)
453
+ longestPartial = match;
454
+ continue;
387
455
  }
456
+ if (
457
+ !best ||
458
+ match.pattern.priority > best.pattern.priority ||
459
+ (match.pattern.priority === best.pattern.priority &&
460
+ match.confidence > best.confidence + 0.001)
461
+ )
462
+ best = match;
388
463
  }
389
464
 
465
+ if (best) {
466
+ const node = this.nodeOf(best, input, language);
467
+ return { node, confidence: best.confidence };
468
+ }
469
+ if (longestPartial) {
470
+ const unread = tokens.tokens
471
+ .slice(longestPartial.consumedTokens)
472
+ .map(t => t.value)
473
+ .join(' ');
474
+ throw new UnreadInputError(
475
+ input,
476
+ language,
477
+ this.nodeOf(longestPartial, input, language),
478
+ unread
479
+ );
480
+ }
390
481
  throw new Error(`No pattern matched for input: ${input}`);
391
482
  }
392
483
 
484
+ private nodeOf(match: PatternMatchResult, input: string, language: string): SemanticNode {
485
+ return {
486
+ kind: 'command',
487
+ action: match.pattern.command,
488
+ roles: match.captured,
489
+ metadata: {
490
+ sourceLanguage: language,
491
+ sourceText: input,
492
+ patternId: match.pattern.id,
493
+ confidence: match.confidence,
494
+ },
495
+ };
496
+ }
497
+
393
498
  validate(input: string, language: string): ValidationResult {
394
499
  try {
395
500
  const result = this.parseWithConfidence(input, language);
@@ -401,11 +506,17 @@ class MultilingualDSLImpl implements MultilingualDSL {
401
506
  return {
402
507
  valid: false,
403
508
  errors: [error instanceof Error ? error.message : String(error)],
509
+ ...(error instanceof UnreadInputError ? { errorCode: error.code } : {}),
404
510
  };
405
511
  }
406
512
  }
407
513
 
408
- translate(input: string, fromLanguage: string, toLanguage: string): string {
514
+ translate(
515
+ input: string,
516
+ fromLanguage: string,
517
+ toLanguage: string,
518
+ options?: TranslateOptions
519
+ ): string {
409
520
  // Explicit syntax is language-agnostic — return unchanged
410
521
  if (isExplicitSyntax(input)) return input;
411
522
 
@@ -432,12 +543,18 @@ class MultilingualDSLImpl implements MultilingualDSL {
432
543
  const rendered = this.registry.getSupportedLanguages().includes(toLanguage)
433
544
  ? this.render(node, toLanguage)
434
545
  : null;
435
- if (rendered != null) return rendered;
546
+ if (rendered != null) return this.readsBack(rendered, node, fromLanguage, toLanguage, options);
436
547
 
437
548
  // render() covered nothing for this action/language pair; fall back to the
438
549
  // grammar transformer when profiles exist, otherwise name both remedies.
439
550
  if (this.hasGrammarProfile(fromLanguage) && this.hasGrammarProfile(toLanguage)) {
440
- return this.transformer.transform(input, fromLanguage, toLanguage);
551
+ return this.readsBack(
552
+ this.transformer.transform(input, fromLanguage, toLanguage),
553
+ node,
554
+ fromLanguage,
555
+ toLanguage,
556
+ options
557
+ );
441
558
  }
442
559
  throw new Error(
443
560
  `translate() could not render action "${node.action}" in language "${toLanguage}": no ` +
@@ -448,6 +565,40 @@ class MultilingualDSLImpl implements MultilingualDSL {
448
565
  );
449
566
  }
450
567
 
568
+ /**
569
+ * `rendered`, read in `to`, must be `source`: the same action and role values, or, when the DSL
570
+ * compiles, the same code (a parser may keep a translated word its generator normalizes: bdd ja
571
+ * `表示` is `visible`). The rule is the domain gate's (lokascript-domains). A translation used to be
572
+ * returned whatever it read as: ja/ko bdd `select` dropped its value, and nothing said so.
573
+ */
574
+ private readsBack(
575
+ rendered: string,
576
+ source: SemanticNode,
577
+ from: string,
578
+ to: string,
579
+ options?: TranslateOptions
580
+ ): string {
581
+ if (options?.lossy === 'allow') return rendered;
582
+ let back: SemanticNode;
583
+ try {
584
+ back = this.parseWithConfidence(rendered, to).node;
585
+ } catch (error) {
586
+ const why = error instanceof Error ? error.message : String(error);
587
+ throw new TranslationReadBackError(rendered, from, to, source, null, why);
588
+ }
589
+ const difference = readingDifference(source, back);
590
+ if (!difference) return rendered;
591
+ if (this.codeGenerator) {
592
+ try {
593
+ if (this.codeGenerator.generate(back) === this.codeGenerator.generate(source))
594
+ return rendered;
595
+ } catch {
596
+ // a generator that cannot compile one of them does not vouch for either
597
+ }
598
+ }
599
+ throw new TranslationReadBackError(rendered, from, to, source, back, difference);
600
+ }
601
+
451
602
  private hasGrammarProfile(language: string): boolean {
452
603
  return this.profileProvider.getProfile(language) != null;
453
604
  }
@@ -477,6 +628,7 @@ class MultilingualDSLImpl implements MultilingualDSL {
477
628
  return {
478
629
  ok: false,
479
630
  errors: [error instanceof Error ? error.message : String(error)],
631
+ ...(error instanceof UnreadInputError ? { errorCode: error.code } : {}),
480
632
  };
481
633
  }
482
634
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { DomainRegistry } from './domain-registry';
3
3
  import type { DomainDescriptor, MCPToolDefinition } from './domain-registry';
4
- import type { MultilingualDSL } from './create-dsl';
4
+ import { UnreadInputError, type MultilingualDSL } from './create-dsl';
5
5
  import type { SemanticNode } from '../core/types';
6
6
 
7
7
  // =============================================================================
@@ -11,10 +11,19 @@ import type { SemanticNode } from '../core/types';
11
11
  function createMockDSL(): MultilingualDSL {
12
12
  return {
13
13
  parse(input: string, language: string): SemanticNode {
14
+ // A rendering is `[<lang>] <source>`: read the source back out of it.
15
+ const source = input.replace(/^\[\w+\] /, '');
16
+ if (source.includes(' zzqx'))
17
+ throw new UnreadInputError(
18
+ input,
19
+ language,
20
+ this.parse(source.replace(' zzqx', ''), language),
21
+ 'zzqx'
22
+ );
14
23
  return {
15
24
  kind: 'command',
16
- action: input.split(/\s+/)[0].toLowerCase(),
17
- roles: new Map([['raw', { type: 'expression' as const, raw: input }]]),
25
+ action: source.split(/\s+/)[0].toLowerCase(),
26
+ roles: new Map([['raw', { type: 'expression' as const, raw: source }]]),
18
27
  metadata: { sourceLanguage: language },
19
28
  };
20
29
  },
@@ -33,7 +42,9 @@ function createMockDSL(): MultilingualDSL {
33
42
  try {
34
43
  const node = this.parse(input, language);
35
44
  return { ok: true, code: `compiled(${input})`, node };
36
- } catch {
45
+ } catch (error) {
46
+ if (error instanceof UnreadInputError)
47
+ return { ok: false, errors: [error.message], errorCode: error.code };
37
48
  return { ok: false, errors: ['Compile failed'] };
38
49
  }
39
50
  },
@@ -46,7 +57,14 @@ function createMockDSL(): MultilingualDSL {
46
57
  };
47
58
  }
48
59
 
60
+ /** Writes the whole source, so a rendering reads back. */
49
61
  function mockRenderer(node: SemanticNode, language: string): string {
62
+ const raw = (node.roles.get('raw') as { raw?: string } | undefined)?.raw ?? node.action;
63
+ return `[${language}] ${raw}`;
64
+ }
65
+
66
+ /** Writes the action alone: `select name` comes out as `select`, which does not read back. */
67
+ function lossyRenderer(node: SemanticNode, language: string): string {
50
68
  return `[${language}] ${node.action}`;
51
69
  }
52
70
 
@@ -236,6 +254,19 @@ describe('DomainRegistry', () => {
236
254
  expect(data.ok).toBe(true);
237
255
  expect(data.code).toBe('compiled(select name)');
238
256
  });
257
+
258
+ it('reports input left unread as an UNREAD_INPUT diagnostic', async () => {
259
+ registry.register(createTestDescriptor());
260
+ const result = await registry.handleToolCall('compile_test', {
261
+ query: 'select name zzqx',
262
+ language: 'en',
263
+ });
264
+
265
+ const data = JSON.parse(result!.content[0].text);
266
+ expect(data.ok).toBe(false);
267
+ expect(data.diagnostics[0].code).toBe('UNREAD_INPUT');
268
+ expect(data.diagnostics[0].message).toMatch(/zzqx/);
269
+ });
239
270
  });
240
271
 
241
272
  describe('validate', () => {
@@ -263,10 +294,44 @@ describe('DomainRegistry', () => {
263
294
  const data = JSON.parse(result!.content[0].text);
264
295
  expect(data.input.query).toBe('select name');
265
296
  expect(data.input.language).toBe('en');
266
- expect(data.rendered.text).toBe('[ja] select');
297
+ expect(data.ok).toBe(true);
298
+ expect(data.rendered.text).toBe('[ja] select name');
267
299
  expect(data.rendered.language).toBe('ja');
268
300
  });
269
301
 
302
+ it('refuses a rendering that does not read back, with its text under partial', async () => {
303
+ registry.register(createTestDescriptor({ getRenderer: () => lossyRenderer }));
304
+ const result = await registry.handleToolCall('translate_test', {
305
+ query: 'select name',
306
+ from: 'en',
307
+ to: 'ja',
308
+ });
309
+
310
+ expect(result!.isError).toBe(true);
311
+ const data = JSON.parse(result!.content[0].text);
312
+ expect(data.ok).toBe(false);
313
+ expect(data.partial).toBe('[ja] select');
314
+ expect(data.rendered).toBeUndefined();
315
+ expect(data.diagnostics[0].code).toBe('TRANSLATION_DOES_NOT_READ_BACK');
316
+ expect(data.diagnostics[0].message).toMatch(/raw: select name → select/);
317
+ });
318
+
319
+ it('refuses input left unread, naming it', async () => {
320
+ registry.register(createTestDescriptor());
321
+ const result = await registry.handleToolCall('translate_test', {
322
+ query: 'select name zzqx',
323
+ from: 'en',
324
+ to: 'ja',
325
+ });
326
+
327
+ expect(result!.isError).toBe(true);
328
+ const data = JSON.parse(result!.content[0].text);
329
+ expect(data.ok).toBe(false);
330
+ expect(data.unread).toBe('zzqx');
331
+ expect(data.reading.action).toBe('select');
332
+ expect(data.diagnostics[0].code).toBe('UNREAD_INPUT');
333
+ });
334
+
270
335
  it('returns error when from/to missing', async () => {
271
336
  registry.register(createTestDescriptor());
272
337