@lokascript/framework 4.5.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 (123) hide show
  1. package/CHANGELOG.md +220 -8
  2. package/dist/api/create-dsl.d.ts +59 -1
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/domain-registry.d.ts.map +1 -1
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +1938 -724
  8. package/dist/api/index.js.map +1 -1
  9. package/dist/api/reading.d.ts +8 -0
  10. package/dist/api/reading.d.ts.map +1 -0
  11. package/dist/api/translation-loss.d.ts +84 -0
  12. package/dist/api/translation-loss.d.ts.map +1 -0
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.d.ts.map +1 -1
  15. package/dist/core/index.js +1047 -684
  16. package/dist/core/index.js.map +1 -1
  17. package/dist/core/matching/confidence-model.d.ts +54 -0
  18. package/dist/core/matching/confidence-model.d.ts.map +1 -0
  19. package/dist/core/matching/index.d.ts +12 -0
  20. package/dist/core/matching/index.d.ts.map +1 -0
  21. package/dist/core/matching/match-skeleton.d.ts +114 -0
  22. package/dist/core/matching/match-skeleton.d.ts.map +1 -0
  23. package/dist/core/matching/stock-confidence.d.ts +15 -0
  24. package/dist/core/matching/stock-confidence.d.ts.map +1 -0
  25. package/dist/core/matching/stock-reader.d.ts +56 -0
  26. package/dist/core/matching/stock-reader.d.ts.map +1 -0
  27. package/dist/core/matching/stock-reading.d.ts +44 -0
  28. package/dist/core/matching/stock-reading.d.ts.map +1 -0
  29. package/dist/core/matching/value-reader.d.ts +62 -0
  30. package/dist/core/matching/value-reader.d.ts.map +1 -0
  31. package/dist/core/pattern-matching/index.js +561 -660
  32. package/dist/core/pattern-matching/index.js.map +1 -1
  33. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
  34. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  35. package/dist/core/types.d.ts +48 -0
  36. package/dist/core/types.d.ts.map +1 -1
  37. package/dist/core/types.js.map +1 -1
  38. package/dist/generation/index.d.ts +2 -0
  39. package/dist/generation/index.d.ts.map +1 -1
  40. package/dist/generation/index.js +583 -1
  41. package/dist/generation/index.js.map +1 -1
  42. package/dist/generation/pattern-render.d.ts +72 -0
  43. package/dist/generation/pattern-render.d.ts.map +1 -0
  44. package/dist/generation/schema-patterns.d.ts +152 -0
  45. package/dist/generation/schema-patterns.d.ts.map +1 -0
  46. package/dist/index.cjs +2132 -890
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.js +2110 -887
  49. package/dist/index.js.map +1 -1
  50. package/dist/ir/index.js.map +1 -1
  51. package/dist/parsing/index.js +561 -660
  52. package/dist/parsing/index.js.map +1 -1
  53. package/dist/testing/gate/index.d.ts +8 -0
  54. package/dist/testing/gate/index.d.ts.map +1 -0
  55. package/dist/testing/gate/index.js +632 -0
  56. package/dist/testing/gate/index.js.map +1 -0
  57. package/dist/testing/gate/lint/format.d.ts +7 -0
  58. package/dist/testing/gate/lint/format.d.ts.map +1 -0
  59. package/dist/testing/gate/lint/index.d.ts +30 -0
  60. package/dist/testing/gate/lint/index.d.ts.map +1 -0
  61. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
  62. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
  63. package/dist/testing/gate/lint/rules/index.d.ts +14 -0
  64. package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
  65. package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
  66. package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
  67. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
  68. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
  69. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
  70. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
  71. package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
  72. package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
  73. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
  74. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
  75. package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
  76. package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
  77. package/dist/testing/gate/lint/types.d.ts +74 -0
  78. package/dist/testing/gate/lint/types.d.ts.map +1 -0
  79. package/dist/testing/gate/round-trip.d.ts +163 -0
  80. package/dist/testing/gate/round-trip.d.ts.map +1 -0
  81. package/package.json +7 -3
  82. package/src/__test__/fixtures/sql-dsl.ts +11 -2
  83. package/src/__test__/sql-integration.test.ts +2 -2
  84. package/src/api/create-dsl-engine.test.ts +86 -0
  85. package/src/api/create-dsl.test.ts +120 -0
  86. package/src/api/create-dsl.ts +314 -39
  87. package/src/api/domain-registry.test.ts +70 -5
  88. package/src/api/domain-registry.ts +82 -1
  89. package/src/api/index.ts +1 -0
  90. package/src/api/reading.ts +29 -0
  91. package/src/api/translation-loss.test.ts +89 -0
  92. package/src/api/translation-loss.ts +128 -0
  93. package/src/core/index.ts +1 -0
  94. package/src/core/matching/confidence-model.ts +58 -0
  95. package/src/core/matching/index.ts +12 -0
  96. package/src/core/matching/match-skeleton.test.ts +179 -0
  97. package/src/core/matching/match-skeleton.ts +621 -0
  98. package/src/core/matching/stock-confidence.ts +238 -0
  99. package/src/core/matching/stock-reader.test.ts +181 -0
  100. package/src/core/matching/stock-reader.ts +186 -0
  101. package/src/core/matching/stock-reading.ts +681 -0
  102. package/src/core/matching/value-reader.ts +85 -0
  103. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  104. package/src/core/types.ts +48 -0
  105. package/src/generation/index.ts +2 -0
  106. package/src/generation/pattern-render.ts +378 -0
  107. package/src/generation/schema-patterns.test.ts +99 -0
  108. package/src/generation/schema-patterns.ts +962 -0
  109. package/src/testing/gate/index.ts +8 -0
  110. package/src/testing/gate/lint/format.ts +23 -0
  111. package/src/testing/gate/lint/index.ts +73 -0
  112. package/src/testing/gate/lint/lint.test.ts +131 -0
  113. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  114. package/src/testing/gate/lint/rules/index.ts +37 -0
  115. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  116. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  117. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  118. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  119. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  120. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  121. package/src/testing/gate/lint/types.ts +83 -0
  122. package/src/testing/gate/round-trip.test.ts +118 -0
  123. package/src/testing/gate/round-trip.ts +438 -0
@@ -2,11 +2,25 @@
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
+ RolePatternToken,
11
+ TokenStream,
12
+ } from '../core/types';
13
+ import { extractRoleValue } from '../core/types';
6
14
  import type { CommandSchema } from '../schema';
7
- import { PatternMatcher, type PatternMatcherProfile } from '../core/pattern-matching';
15
+ import { PatternMatcher } from '../core/pattern-matching';
16
+ import { MatchSkeleton, createStockReader, stockConfidenceModel } from '../core/matching';
8
17
  import { generatePattern, type PatternGenLanguageProfile } from '../generation/pattern-generator';
9
18
  import { createDomainRenderer, type DomainRenderFn } from '../generation/renderer';
19
+ import {
20
+ createSchemaPatternGenerator,
21
+ type SchemaPatternProfile,
22
+ } from '../generation/schema-patterns';
23
+ import { renderPattern, selectRenderPattern } from '../generation/pattern-render';
10
24
  import { GrammarTransformer, type LanguageProfile as GrammarProfile } from '../grammar';
11
25
  import {
12
26
  type Dictionary,
@@ -16,6 +30,7 @@ import {
16
30
  InMemoryProfileProvider,
17
31
  } from '../interfaces';
18
32
  import { isExplicitSyntax, parseExplicit } from '../ir';
33
+ import { readingDifference } from './reading';
19
34
  import type { SchemaLookup } from '../ir';
20
35
 
21
36
  /**
@@ -168,6 +183,20 @@ export interface DSLConfig {
168
183
 
169
184
  /** Custom patterns to supplement generated ones */
170
185
  readonly customPatterns?: LanguagePattern[];
186
+
187
+ /**
188
+ * What reads and writes the DSL (domains rethink Phase 4.7). `'classic'`, the
189
+ * default: framework's first pattern generator, matcher and schema renderer.
190
+ * `'core'`: the core semantic's hyperscript parser runs — patterns from
191
+ * `createSchemaPatternGenerator` (positions descending, as a domain's schemas
192
+ * read), matched by `MatchSkeleton` with the stock reader and confidence
193
+ * model, and rendered through those patterns where the domain's own renderer
194
+ * declines. Parsing's whole-input rule, the refusals, translation's
195
+ * read-back, compile, explicit syntax and extensions are the same on both.
196
+ * The domains move to `'core'` one at a time against their gate (Phase 5);
197
+ * `'classic'` is removed in a later major, once no domain uses it.
198
+ */
199
+ readonly engine?: 'classic' | 'core';
171
200
  }
172
201
 
173
202
  /**
@@ -177,6 +206,8 @@ export interface ValidationResult {
177
206
  readonly valid: boolean;
178
207
  readonly node?: SemanticNode;
179
208
  readonly errors?: string[];
209
+ /** Set when the input was refused for a known reason: `'unread-input'` (`UnreadInputError`). */
210
+ readonly errorCode?: string;
180
211
  }
181
212
 
182
213
  /**
@@ -186,6 +217,8 @@ export interface CompileResult {
186
217
  readonly ok: boolean;
187
218
  readonly code?: string;
188
219
  readonly errors?: string[];
220
+ /** Set when the input was refused for a known reason: `'unread-input'` (`UnreadInputError`). */
221
+ readonly errorCode?: string;
189
222
  readonly node?: SemanticNode;
190
223
  readonly metadata?: {
191
224
  readonly parser: string;
@@ -193,6 +226,55 @@ export interface CompileResult {
193
226
  };
194
227
  }
195
228
 
229
+ /**
230
+ * Thrown by `parse` (and so `compile`, `validate`, `translate`) when the input reads as a command
231
+ * only by leaving some of it unread: `select name from users banana` is not `select name from users`.
232
+ * `reading` is what the longest such match read, `unread` the text after it.
233
+ */
234
+ export class UnreadInputError extends Error {
235
+ readonly code = 'unread-input';
236
+
237
+ constructor(
238
+ readonly input: string,
239
+ readonly language: string,
240
+ readonly reading: SemanticNode,
241
+ readonly unread: string
242
+ ) {
243
+ super(`"${input}" (${language}) reads as ${reading.action} only by leaving "${unread}" unread`);
244
+ this.name = 'UnreadInputError';
245
+ }
246
+ }
247
+
248
+ /** Options for `translate`. */
249
+ export interface TranslateOptions {
250
+ /**
251
+ * `'allow'` returns a translation that does not read back as the source instead of throwing
252
+ * `TranslationReadBackError`, as semantic's `translate` does with the same option.
253
+ */
254
+ readonly lossy?: 'allow' | 'refuse';
255
+ }
256
+
257
+ /**
258
+ * Thrown by `translate` when its output, read in the target language, is not the source command:
259
+ * it does not read at all, or reads as another command, or with a role lost or changed. `partial` is
260
+ * what `translate` would have returned; `reading` what it reads as (null when it does not read).
261
+ */
262
+ export class TranslationReadBackError extends Error {
263
+ readonly code = 'translation-does-not-read-back';
264
+
265
+ constructor(
266
+ readonly partial: string,
267
+ readonly from: string,
268
+ readonly to: string,
269
+ readonly source: SemanticNode,
270
+ readonly reading: SemanticNode | null,
271
+ readonly why: string
272
+ ) {
273
+ super(`translation ${from} → ${to} does not read back as the source (${why}): "${partial}"`);
274
+ this.name = 'TranslationReadBackError';
275
+ }
276
+ }
277
+
196
278
  /**
197
279
  * Main multilingual DSL interface.
198
280
  */
@@ -208,7 +290,17 @@ export interface MultilingualDSL {
208
290
  compile(input: string, language: string): CompileResult;
209
291
 
210
292
  // Translation
211
- translate(input: string, fromLanguage: string, toLanguage: string): string;
293
+ /**
294
+ * Parse in one language, render in another. The output must read back as the source command
295
+ * (the same action and role values, or the same compiled code); when it does not,
296
+ * `TranslationReadBackError`, unless `{ lossy: 'allow' }`.
297
+ */
298
+ translate(
299
+ input: string,
300
+ fromLanguage: string,
301
+ toLanguage: string,
302
+ options?: TranslateOptions
303
+ ): string;
212
304
 
213
305
  /**
214
306
  * Render a parsed node back to natural language.
@@ -239,7 +331,11 @@ class DSLRegistry {
239
331
  * subset of the DSL's languages. A built-in command missing a keyword stays
240
332
  * an error, since that is a domain-authoring mistake.
241
333
  */
242
- constructor(config: DSLConfig, partiallyTranslatedActions: ReadonlySet<string> = new Set()) {
334
+ constructor(
335
+ config: DSLConfig,
336
+ partiallyTranslatedActions: ReadonlySet<string>,
337
+ private readonly generate: DSLEngine['generate']
338
+ ) {
243
339
  this.schemas = config.schemas;
244
340
  this.partiallyTranslatedActions = partiallyTranslatedActions;
245
341
 
@@ -262,7 +358,7 @@ class DSLRegistry {
262
358
  ) {
263
359
  continue; // this extension command was not translated into this language
264
360
  }
265
- const pattern = generatePattern(schema, lang.patternProfile);
361
+ const pattern = this.generate(schema, lang.patternProfile);
266
362
  patterns.push(pattern);
267
363
  }
268
364
 
@@ -287,7 +383,7 @@ class DSLRegistry {
287
383
  */
288
384
  class MultilingualDSLImpl implements MultilingualDSL {
289
385
  private registry: DSLRegistry;
290
- private matcher: PatternMatcher;
386
+ private match: DSLEngine['match'];
291
387
  private transformer: GrammarTransformer;
292
388
  private profileProvider: ProfileProvider;
293
389
  private codeGenerator?: CodeGenerator;
@@ -300,10 +396,11 @@ class MultilingualDSLImpl implements MultilingualDSL {
300
396
  config: DSLConfig,
301
397
  registry: DSLRegistry,
302
398
  transformer: GrammarTransformer,
303
- profileProvider: ProfileProvider
399
+ profileProvider: ProfileProvider,
400
+ engine: DSLEngine
304
401
  ) {
305
402
  this.registry = registry;
306
- this.matcher = new PatternMatcher();
403
+ this.match = engine.match;
307
404
  this.transformer = transformer;
308
405
  this.profileProvider = profileProvider;
309
406
  if (config.codeGenerator) {
@@ -321,10 +418,7 @@ class MultilingualDSLImpl implements MultilingualDSL {
321
418
  // Last resort for render(): covers extension commands with no custom
322
419
  // renderer, and any built-in action the domain renderer declines. Lazy, so
323
420
  // a DSL that never renders pays nothing.
324
- this.schemaFallbackRenderer = createDomainRenderer({
325
- schemas: config.schemas,
326
- profiles: config.languages.map(l => l.patternProfile),
327
- });
421
+ this.schemaFallbackRenderer = engine.fallbackRenderer(config, registry);
328
422
 
329
423
  // Build SchemaLookup from config schemas for explicit syntax validation
330
424
  const schemaMap = new Map(config.schemas.map(s => [s.action, s]));
@@ -361,35 +455,66 @@ class MultilingualDSLImpl implements MultilingualDSL {
361
455
  // Get patterns for language
362
456
  const patterns = this.registry.getPatterns(language);
363
457
 
364
- // Create profile for matcher
365
- const profile: PatternMatcherProfile = {
366
- code: language,
367
- };
368
-
369
- // Try to match each pattern
458
+ // Every pattern is tried, and only a match that reads the WHOLE input counts; of those, the
459
+ // highest priority wins, then the highest confidence, then the earliest pattern. The first match
460
+ // used to win however much it read, so a trailing word was dropped without a word (the domain
461
+ // gate measured 1,744 of 1,744 such inputs accepted) and an earlier, shorter pattern could
462
+ // shadow the one that reads everything.
463
+ const total = tokens.tokens.length;
464
+ let best: PatternMatchResult | null = null;
465
+ let longestPartial: PatternMatchResult | null = null;
370
466
  for (const pattern of patterns) {
371
- 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 };
467
+ const mark = tokens.mark();
468
+ const match = this.match(tokens, pattern, language);
469
+ tokens.reset(mark);
470
+ if (!match) continue;
471
+ if (match.consumedTokens < total) {
472
+ if (!longestPartial || match.consumedTokens > longestPartial.consumedTokens)
473
+ longestPartial = match;
474
+ continue;
387
475
  }
476
+ if (
477
+ !best ||
478
+ match.pattern.priority > best.pattern.priority ||
479
+ (match.pattern.priority === best.pattern.priority &&
480
+ match.confidence > best.confidence + 0.001)
481
+ )
482
+ best = match;
388
483
  }
389
484
 
485
+ if (best) {
486
+ const node = this.nodeOf(best, input, language);
487
+ return { node, confidence: best.confidence };
488
+ }
489
+ if (longestPartial) {
490
+ const unread = tokens.tokens
491
+ .slice(longestPartial.consumedTokens)
492
+ .map(t => t.value)
493
+ .join(' ');
494
+ throw new UnreadInputError(
495
+ input,
496
+ language,
497
+ this.nodeOf(longestPartial, input, language),
498
+ unread
499
+ );
500
+ }
390
501
  throw new Error(`No pattern matched for input: ${input}`);
391
502
  }
392
503
 
504
+ private nodeOf(match: PatternMatchResult, input: string, language: string): SemanticNode {
505
+ return {
506
+ kind: 'command',
507
+ action: match.pattern.command,
508
+ roles: match.captured,
509
+ metadata: {
510
+ sourceLanguage: language,
511
+ sourceText: input,
512
+ patternId: match.pattern.id,
513
+ confidence: match.confidence,
514
+ },
515
+ };
516
+ }
517
+
393
518
  validate(input: string, language: string): ValidationResult {
394
519
  try {
395
520
  const result = this.parseWithConfidence(input, language);
@@ -401,11 +526,17 @@ class MultilingualDSLImpl implements MultilingualDSL {
401
526
  return {
402
527
  valid: false,
403
528
  errors: [error instanceof Error ? error.message : String(error)],
529
+ ...(error instanceof UnreadInputError ? { errorCode: error.code } : {}),
404
530
  };
405
531
  }
406
532
  }
407
533
 
408
- translate(input: string, fromLanguage: string, toLanguage: string): string {
534
+ translate(
535
+ input: string,
536
+ fromLanguage: string,
537
+ toLanguage: string,
538
+ options?: TranslateOptions
539
+ ): string {
409
540
  // Explicit syntax is language-agnostic — return unchanged
410
541
  if (isExplicitSyntax(input)) return input;
411
542
 
@@ -432,12 +563,18 @@ class MultilingualDSLImpl implements MultilingualDSL {
432
563
  const rendered = this.registry.getSupportedLanguages().includes(toLanguage)
433
564
  ? this.render(node, toLanguage)
434
565
  : null;
435
- if (rendered != null) return rendered;
566
+ if (rendered != null) return this.readsBack(rendered, node, fromLanguage, toLanguage, options);
436
567
 
437
568
  // render() covered nothing for this action/language pair; fall back to the
438
569
  // grammar transformer when profiles exist, otherwise name both remedies.
439
570
  if (this.hasGrammarProfile(fromLanguage) && this.hasGrammarProfile(toLanguage)) {
440
- return this.transformer.transform(input, fromLanguage, toLanguage);
571
+ return this.readsBack(
572
+ this.transformer.transform(input, fromLanguage, toLanguage),
573
+ node,
574
+ fromLanguage,
575
+ toLanguage,
576
+ options
577
+ );
441
578
  }
442
579
  throw new Error(
443
580
  `translate() could not render action "${node.action}" in language "${toLanguage}": no ` +
@@ -448,6 +585,40 @@ class MultilingualDSLImpl implements MultilingualDSL {
448
585
  );
449
586
  }
450
587
 
588
+ /**
589
+ * `rendered`, read in `to`, must be `source`: the same action and role values, or, when the DSL
590
+ * compiles, the same code (a parser may keep a translated word its generator normalizes: bdd ja
591
+ * `表示` is `visible`). The rule is the domain gate's (lokascript-domains). A translation used to be
592
+ * returned whatever it read as: ja/ko bdd `select` dropped its value, and nothing said so.
593
+ */
594
+ private readsBack(
595
+ rendered: string,
596
+ source: SemanticNode,
597
+ from: string,
598
+ to: string,
599
+ options?: TranslateOptions
600
+ ): string {
601
+ if (options?.lossy === 'allow') return rendered;
602
+ let back: SemanticNode;
603
+ try {
604
+ back = this.parseWithConfidence(rendered, to).node;
605
+ } catch (error) {
606
+ const why = error instanceof Error ? error.message : String(error);
607
+ throw new TranslationReadBackError(rendered, from, to, source, null, why);
608
+ }
609
+ const difference = readingDifference(source, back);
610
+ if (!difference) return rendered;
611
+ if (this.codeGenerator) {
612
+ try {
613
+ if (this.codeGenerator.generate(back) === this.codeGenerator.generate(source))
614
+ return rendered;
615
+ } catch {
616
+ // a generator that cannot compile one of them does not vouch for either
617
+ }
618
+ }
619
+ throw new TranslationReadBackError(rendered, from, to, source, back, difference);
620
+ }
621
+
451
622
  private hasGrammarProfile(language: string): boolean {
452
623
  return this.profileProvider.getProfile(language) != null;
453
624
  }
@@ -477,6 +648,7 @@ class MultilingualDSLImpl implements MultilingualDSL {
477
648
  return {
478
649
  ok: false,
479
650
  errors: [error instanceof Error ? error.message : String(error)],
651
+ ...(error instanceof UnreadInputError ? { errorCode: error.code } : {}),
480
652
  };
481
653
  }
482
654
  }
@@ -678,6 +850,109 @@ export function createMultilingualDSL(config: DSLConfig): MultilingualDSL {
678
850
  });
679
851
 
680
852
  // Create registry and implementation
681
- const registry = new DSLRegistry(effectiveConfig, extensionActions);
682
- return new MultilingualDSLImpl(effectiveConfig, registry, transformer, profileProvider);
853
+ const engine = engineFor(config);
854
+ const registry = new DSLRegistry(effectiveConfig, extensionActions, engine.generate);
855
+ return new MultilingualDSLImpl(effectiveConfig, registry, transformer, profileProvider, engine);
856
+ }
857
+
858
+ // =============================================================================
859
+ // The engines: how a DSL turns its schemas into patterns and reads with them
860
+ // =============================================================================
861
+
862
+ /**
863
+ * What differs between the engines {@link DSLConfig.engine} names: how each
864
+ * command's pattern is generated, how input is matched against it, and the
865
+ * renderer `render()` falls back to. Parsing's whole-input rule, refusals,
866
+ * translation's read-back, compile, explicit syntax and extensions are shared.
867
+ */
868
+ interface DSLEngine {
869
+ generate(schema: CommandSchema, profile: PatternGenLanguageProfile): LanguagePattern;
870
+ match(tokens: TokenStream, pattern: LanguagePattern, language: string): PatternMatchResult | null;
871
+ fallbackRenderer(config: DSLConfig, registry: DSLRegistry): DomainRenderFn;
872
+ }
873
+
874
+ /**
875
+ * The engine `config.engine` names. An unknown name throws rather than reading
876
+ * with the default: a domain that misspells `'core'` would otherwise measure
877
+ * the classic engine against its gate and take the result for the core's.
878
+ */
879
+ function engineFor(config: DSLConfig): DSLEngine {
880
+ const name = config.engine ?? 'classic';
881
+ if (name === 'classic') return classicEngine();
882
+ if (name === 'core') return coreEngine();
883
+ throw new Error(`Unknown DSL engine "${String(name)}" (expected "classic" or "core")`);
884
+ }
885
+
886
+ /** framework's first generator and matcher, and its schema renderer (`engine: 'classic'`). */
887
+ function classicEngine(): DSLEngine {
888
+ const matcher = new PatternMatcher();
889
+ return {
890
+ generate: (schema, profile) => generatePattern(schema, profile),
891
+ match: (tokens, pattern, language) => matcher.matchPattern(tokens, pattern, { code: language }),
892
+ fallbackRenderer: config =>
893
+ createDomainRenderer({
894
+ schemas: config.schemas,
895
+ profiles: config.languages.map(l => l.patternProfile),
896
+ }),
897
+ };
898
+ }
899
+
900
+ /**
901
+ * A domain profile as the schema-pattern generator reads it: every role marker
902
+ * with a side (the word order's default where the profile names none).
903
+ */
904
+ function schemaProfileOf(profile: PatternGenLanguageProfile): SchemaPatternProfile {
905
+ const side = profile.wordOrder === 'SOV' ? 'after' : 'before';
906
+ const roleMarkers: Record<string, NonNullable<SchemaPatternProfile['roleMarkers'][string]>> = {};
907
+ for (const [role, marker] of Object.entries(profile.roleMarkers ?? {})) {
908
+ roleMarkers[role] = {
909
+ primary: marker.primary,
910
+ ...(marker.alternatives ? { alternatives: marker.alternatives } : {}),
911
+ position: marker.position ?? side,
912
+ };
913
+ }
914
+ return {
915
+ code: profile.code,
916
+ wordOrder: profile.wordOrder,
917
+ keywords: profile.keywords,
918
+ roleMarkers,
919
+ };
920
+ }
921
+
922
+ /**
923
+ * The core (domains rethink Phase 4, `engine: 'core'`): the schema-pattern
924
+ * generator, the skeleton, the stock reader.
925
+ */
926
+ function coreEngine(): DSLEngine {
927
+ const generator = createSchemaPatternGenerator({ positionOrder: 'descending' });
928
+ const matcher = new MatchSkeleton(createStockReader(), stockConfidenceModel);
929
+ return {
930
+ generate: (schema, profile) => generator.generatePattern(schema, schemaProfileOf(profile)),
931
+ match: (tokens, pattern) => matcher.matchPattern(tokens, pattern),
932
+ fallbackRenderer: (config, registry) => {
933
+ const schemas = new Map(config.schemas.map(s => [s.action, s]));
934
+ const hooks = {
935
+ hasRealRole: (node: SemanticNode, role: string) => {
936
+ const value = node.roles.get(role) as { implicit?: unknown } | undefined;
937
+ return value !== undefined && value.implicit !== true;
938
+ },
939
+ writeRole: (token: RolePatternToken, node: SemanticNode) => {
940
+ if (!node.roles.has(token.role)) return null;
941
+ let value = extractRoleValue(node, token.role);
942
+ const spec = schemas.get(node.action)?.roles.find(r => r.role === token.role);
943
+ if (spec?.quoteMultiword && /\s/.test(value) && !/^(["']).*\1$/.test(value)) {
944
+ value = `"${value}"`;
945
+ }
946
+ return value || null;
947
+ },
948
+ };
949
+ return (node, language) => {
950
+ const patterns = registry.getPatterns(language).filter(p => p.command === node.action);
951
+ const pattern = selectRenderPattern(node, patterns, hooks);
952
+ if (!pattern) return null;
953
+ const text = renderPattern(node, pattern, hooks).join(' ');
954
+ return text || null;
955
+ };
956
+ },
957
+ };
683
958
  }
@@ -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