@lokascript/framework 4.4.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/CHANGELOG.md +197 -5
- package/dist/api/create-dsl.d.ts +46 -1
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/domain-registry.d.ts.map +1 -1
- package/dist/api/index.js +187 -19
- package/dist/api/index.js.map +1 -1
- package/dist/api/reading.d.ts +8 -0
- package/dist/api/reading.d.ts.map +1 -0
- package/dist/index.cjs +189 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +187 -19
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__test__/fixtures/sql-dsl.ts +3 -0
- package/src/__test__/sql-integration.test.ts +2 -2
- package/src/api/create-dsl.test.ts +120 -0
- package/src/api/create-dsl.ts +173 -21
- package/src/api/domain-registry.test.ts +70 -5
- package/src/api/domain-registry.ts +82 -1
- package/src/api/reading.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lokascript/framework",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
99
|
+
"@lokascript/intent": "^4.6.0"
|
|
100
100
|
},
|
|
101
101
|
"devDependencies": {
|
|
102
|
-
"@lokascript/domains": "^
|
|
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).
|
|
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
|
});
|
package/src/api/create-dsl.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* Main API for creating multilingual DSLs
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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:
|
|
17
|
-
roles: new Map([['raw', { type: 'expression' as const, raw:
|
|
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.
|
|
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
|
|