@lokascript/framework 2.9.4 → 2.11.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": "2.9.4",
3
+ "version": "2.11.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,9 +96,10 @@
96
96
  "author": "LokaScript Contributors",
97
97
  "license": "MIT",
98
98
  "dependencies": {
99
- "@lokascript/intent": "^2.9.4"
99
+ "@lokascript/intent": "^2.11.0"
100
100
  },
101
101
  "devDependencies": {
102
+ "@lokascript/domains": "^2.11.1",
102
103
  "@types/node": "^26.1.2",
103
104
  "@vitest/coverage-v8": "^4.0.17",
104
105
  "tsup": "^8.0.0",
@@ -0,0 +1,35 @@
1
+ /**
2
+ * API-surface snapshot: the third-party extension contract.
3
+ *
4
+ * Domain packages now live OUTSIDE this repo (lokascript-domains, published as
5
+ * @lokascript/domains), so a signature change here no longer breaks anything
6
+ * in-tree at build time — this test is what makes an accidental change to the
7
+ * promised surface fail loudly instead. DOMAIN_AUTHOR_GUIDE.md documents the
8
+ * promise; docs/API_SURFACE.md is the committed snapshot.
9
+ *
10
+ * On a DELIBERATE contract change: regenerate with
11
+ * `npx tsx scripts/api-surface-report.ts`, commit the diff, and note the
12
+ * change in DOMAIN_AUTHOR_GUIDE.md.
13
+ */
14
+ import { describe, it, expect } from 'vitest';
15
+ import { readFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { extractApiSurface } from '../../scripts/api-surface-lib';
18
+
19
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
20
+
21
+ describe('extension-contract API surface', () => {
22
+ it('matches the committed docs/API_SURFACE.md snapshot', () => {
23
+ const committed = readFileSync(path.join(PKG_ROOT, 'docs', 'API_SURFACE.md'), 'utf8');
24
+ const current = extractApiSurface(PKG_ROOT) + '\n';
25
+ expect(
26
+ current,
27
+ 'API surface drifted from docs/API_SURFACE.md — if deliberate, regenerate with `npx tsx scripts/api-surface-report.ts` and update DOMAIN_AUTHOR_GUIDE.md'
28
+ ).toBe(committed);
29
+ });
30
+
31
+ it('the snapshot pins every promised export (no MISSING markers)', () => {
32
+ const committed = readFileSync(path.join(PKG_ROOT, 'docs', 'API_SURFACE.md'), 'utf8');
33
+ expect(committed).not.toContain('MISSING');
34
+ });
35
+ });
@@ -12,7 +12,7 @@ describe('registryToAOTBackends', () => {
12
12
  languages: ['en'],
13
13
  inputLabel: 'input',
14
14
  inputDescription: 'Test input',
15
- getDSL: () => import('@lokascript/domain-sql').then(m => m.createSQLDSL()),
15
+ getDSL: () => import('@lokascript/domains/sql').then(m => m.createSQLDSL()),
16
16
  scanConfig: {
17
17
  attributes: ['data-test'],
18
18
  scriptTypes: ['text/test'],
@@ -38,7 +38,7 @@ describe('registryToAOTBackends', () => {
38
38
  languages: ['en'],
39
39
  inputLabel: 'input',
40
40
  inputDescription: 'Test',
41
- getDSL: () => import('@lokascript/domain-sql').then(m => m.createSQLDSL()),
41
+ getDSL: () => import('@lokascript/domains/sql').then(m => m.createSQLDSL()),
42
42
  scanConfig: {
43
43
  attributes: ['data-with'],
44
44
  defaultLanguage: 'en',
@@ -51,7 +51,7 @@ describe('registryToAOTBackends', () => {
51
51
  languages: ['en'],
52
52
  inputLabel: 'input',
53
53
  inputDescription: 'Test',
54
- getDSL: () => import('@lokascript/domain-sql').then(m => m.createSQLDSL()),
54
+ getDSL: () => import('@lokascript/domains/sql').then(m => m.createSQLDSL()),
55
55
  // no scanConfig
56
56
  });
57
57
 
@@ -69,7 +69,7 @@ describe('registryToAOTBackends', () => {
69
69
  languages: ['en'],
70
70
  inputLabel: 'input',
71
71
  inputDescription: 'Test',
72
- getDSL: () => import('@lokascript/domain-sql').then(m => m.createSQLDSL()),
72
+ getDSL: () => import('@lokascript/domains/sql').then(m => m.createSQLDSL()),
73
73
  });
74
74
 
75
75
  const backends = await registryToAOTBackends(registry);
@@ -85,7 +85,7 @@ describe('registryToAOTBackends', () => {
85
85
  languages: ['en'],
86
86
  inputLabel: 'input',
87
87
  inputDescription: 'Test',
88
- getDSL: () => import('@lokascript/domain-sql').then(m => m.createSQLDSL()),
88
+ getDSL: () => import('@lokascript/domains/sql').then(m => m.createSQLDSL()),
89
89
  scanConfig: {
90
90
  attributes: ['data-gen'],
91
91
  defaultLanguage: 'en',
@@ -39,7 +39,7 @@ const selectSchema = defineCommand({
39
39
  required: true,
40
40
  expectedTypes: ['expression'],
41
41
  svoPosition: 1,
42
- markerOverride: { en: 'from' },
42
+ markerOverride: { en: 'from', es: 'de' },
43
43
  }),
44
44
  ],
45
45
  });
@@ -100,6 +100,62 @@ const englishProfile = {
100
100
  roleMarkers: {},
101
101
  };
102
102
 
103
+ class SpanishTestTokenizer extends BaseTokenizer {
104
+ readonly language = 'es';
105
+ readonly direction = 'ltr' as const;
106
+
107
+ constructor() {
108
+ super();
109
+ this.registerExtractors(getDefaultExtractors());
110
+ }
111
+
112
+ classifyToken(token: string): 'keyword' | 'identifier' | 'literal' | 'operator' {
113
+ const keywords = ['seleccionar', 'insertar', 'de', 'en'];
114
+ if (keywords.includes(token.toLowerCase())) return 'keyword';
115
+ return 'identifier';
116
+ }
117
+ }
118
+
119
+ const spanishProfile = {
120
+ code: 'es',
121
+ name: 'Spanish',
122
+ nativeName: 'Español',
123
+ wordOrder: 'SVO' as const,
124
+ direction: 'ltr' as const,
125
+ caseMarking: 'preposition' as const,
126
+ keywords: {
127
+ select: { primary: 'seleccionar', aliases: [] },
128
+ insert: { primary: 'insertar', aliases: [] },
129
+ from: { primary: 'de', aliases: [] },
130
+ into: { primary: 'en', aliases: [] },
131
+ },
132
+ roleMarkers: {},
133
+ };
134
+
135
+ /** Two languages, NO grammarProfile on either — the nine-domains shape. */
136
+ function createBilingualTestDSL() {
137
+ return createMultilingualDSL({
138
+ name: 'TestSQL-bilingual',
139
+ schemas: [selectSchema, insertSchema],
140
+ languages: [
141
+ {
142
+ code: 'en',
143
+ name: 'English',
144
+ nativeName: 'English',
145
+ tokenizer: new EnglishTestTokenizer(),
146
+ patternProfile: englishProfile,
147
+ },
148
+ {
149
+ code: 'es',
150
+ name: 'Spanish',
151
+ nativeName: 'Español',
152
+ tokenizer: new SpanishTestTokenizer(),
153
+ patternProfile: spanishProfile,
154
+ },
155
+ ],
156
+ });
157
+ }
158
+
103
159
  function createTestDSL() {
104
160
  return createMultilingualDSL({
105
161
  name: 'TestSQL',
@@ -273,23 +329,49 @@ describe('MultilingualDSL: Explicit Syntax Support', () => {
273
329
  // translate
274
330
  // ---------------------------------------------------------------------------
275
331
 
276
- describe('translate', () => {
332
+ describe('translate (parse→render)', () => {
277
333
  it('returns explicit syntax unchanged', () => {
278
334
  const dsl = createTestDSL();
279
335
  const result = dsl.translate('[select columns:name source:users]', 'en', 'ja');
280
336
  expect(result).toBe('[select columns:name source:users]');
281
337
  });
282
338
 
283
- // The test DSL configures no `grammarProfile`, which is legal for
284
- // parse/validate/compile but not for translate. The error must name the
339
+ it('translates between configured languages with NO grammar profiles (the nine-domains shape)', () => {
340
+ const dsl = createBilingualTestDSL();
341
+ const result = dsl.translate('select name from users', 'en', 'es');
342
+ expect(result).toContain('seleccionar');
343
+ expect(result).toContain('name');
344
+ expect(result).toContain('users');
345
+ // The rendered Spanish is real DSL surface — it re-parses.
346
+ expect(dsl.validate(result, 'es').valid).toBe(true);
347
+ });
348
+
349
+ it('round-trips to the source language through the schema renderer', () => {
350
+ const dsl = createTestDSL();
351
+ const result = dsl.translate('select name from users', 'en', 'en');
352
+ expect(result.toLowerCase()).toContain('select');
353
+ expect(result).toContain('name');
354
+ expect(result).toContain('users');
355
+ expect(dsl.validate(result, 'en').valid).toBe(true);
356
+ });
357
+
358
+ // No renderer coverage for 'ja' (not a configured language) and no
359
+ // grammarProfile for the transformer fallback: the error must name the
285
360
  // field to set rather than leaving the caller with "No profile found".
286
- it('throws an error naming grammarProfile when the profile is missing', () => {
361
+ it('throws an error naming grammarProfile when render cannot cover the target', () => {
287
362
  const dsl = createTestDSL();
288
363
  expect(() => dsl.translate('select name from users', 'en', 'ja')).toThrow(/grammarProfile/);
289
364
  expect(() => dsl.translate('select name from users', 'en', 'ja')).toThrow(
290
365
  /parse\/validate\/compile do not need it/
291
366
  );
292
367
  });
368
+
369
+ it('propagates the parse error for unparseable input when no fallback exists', () => {
370
+ const dsl = createTestDSL();
371
+ expect(() => dsl.translate('gibberish matching nothing', 'en', 'en')).toThrow(
372
+ /No pattern matched/
373
+ );
374
+ });
293
375
  });
294
376
 
295
377
  // ---------------------------------------------------------------------------
@@ -35,8 +35,10 @@ export interface LanguageConfig {
35
35
  /**
36
36
  * Language profile for grammar transformation.
37
37
  *
38
- * Optional for `parse()`, `validate()` and `compile()`, but **required for
39
- * `translate()`** — omitting it makes `translate()` throw for this language.
38
+ * Optional everywhere: `translate()` primarily works parse→render through
39
+ * the schema-driven renderer, which needs no grammar profile. Configure this
40
+ * only to enable the whole-string GrammarTransformer FALLBACK for input the
41
+ * pattern matcher cannot parse (or actions render() cannot cover).
40
42
  */
41
43
  readonly grammarProfile?: GrammarProfile;
42
44
  }
@@ -407,23 +409,47 @@ class MultilingualDSLImpl implements MultilingualDSL {
407
409
  // Explicit syntax is language-agnostic — return unchanged
408
410
  if (isExplicitSyntax(input)) return input;
409
411
 
410
- // translate() is the only path that needs grammar profiles; parse/validate/
411
- // compile work without them. `grammarProfile` is optional on LanguageConfig,
412
- // so omitting it fails here rather than at construction — name the missing
413
- // field instead of leaving the transformer's bare "No profile found".
414
- for (const language of [fromLanguage, toLanguage]) {
415
- if (!this.profileProvider.getProfile(language)) {
416
- throw new Error(
417
- `translate() requires a grammar profile for language "${language}", but none is ` +
418
- `configured. Set 'grammarProfile' on the LanguageConfig for "${language}" in ` +
419
- `createMultilingualDSL() (parse/validate/compile do not need it), or inject a ` +
420
- `custom 'profileProvider'.`
421
- );
412
+ // Primary route: parse in the source language, render in the target — the
413
+ // same parse→render path DomainRegistry.translate uses. The schema-driven
414
+ // renderer covers every configured language, so this needs NO grammar
415
+ // profiles (before 2026-08 translate() required them and therefore threw
416
+ // for every domain in the family — none configures one).
417
+ let node: SemanticNode;
418
+ try {
419
+ node = this.parseWithConfidence(input, fromLanguage).node;
420
+ } catch (parseError) {
421
+ // Whole-string grammar transformation can sometimes translate input the
422
+ // pattern matcher cannot parse — but only when profiles are configured.
423
+ if (this.hasGrammarProfile(fromLanguage) && this.hasGrammarProfile(toLanguage)) {
424
+ return this.transformer.transform(input, fromLanguage, toLanguage);
422
425
  }
426
+ throw parseError;
423
427
  }
424
428
 
425
- // Use injected grammar transformer
426
- return this.transformer.transform(input, fromLanguage, toLanguage);
429
+ // Only consult render() for a CONFIGURED target language — the schema
430
+ // renderer's lookup tables fall back to raw action/marker names for
431
+ // unknown languages, which would silently return wrong-language output.
432
+ const rendered = this.registry.getSupportedLanguages().includes(toLanguage)
433
+ ? this.render(node, toLanguage)
434
+ : null;
435
+ if (rendered != null) return rendered;
436
+
437
+ // render() covered nothing for this action/language pair; fall back to the
438
+ // grammar transformer when profiles exist, otherwise name both remedies.
439
+ if (this.hasGrammarProfile(fromLanguage) && this.hasGrammarProfile(toLanguage)) {
440
+ return this.transformer.transform(input, fromLanguage, toLanguage);
441
+ }
442
+ throw new Error(
443
+ `translate() could not render action "${node.action}" in language "${toLanguage}": no ` +
444
+ `custom renderer, domain renderer, or schema pattern covers it, and no grammar ` +
445
+ `profile is configured for the transformer fallback. Either add "${toLanguage}" to ` +
446
+ `the DSL's languages/renderer, or set 'grammarProfile' on its LanguageConfig in ` +
447
+ `createMultilingualDSL() (parse/validate/compile do not need it).`
448
+ );
449
+ }
450
+
451
+ private hasGrammarProfile(language: string): boolean {
452
+ return this.profileProvider.getProfile(language) != null;
427
453
  }
428
454
 
429
455
  compile(input: string, language: string): CompileResult {
@@ -117,7 +117,7 @@ export interface DomainDescriptor {
117
117
  }
118
118
 
119
119
  // =============================================================================
120
- // MCP Tool Types (minimal — compatible with @modelcontextprotocol/sdk)
120
+ // MCP Tool Types (minimal — compatible with @modelcontextprotocol/server)
121
121
  // =============================================================================
122
122
 
123
123
  /**